UNPKG

regl-scatterplot

Version:

A WebGL-Powered Scalable Interactive Scatter Plot Library

1,702 lines (1,490 loc) 129 kB
import { hasSameElements, identity, max as maxArray, rangeMap, throttleAndDebounce, unionIntegers, } from '@flekschas/utils'; import createDom2dCamera from 'dom-2d-camera'; import earcut from 'earcut'; import { mat4, vec4 } from 'gl-matrix'; import createPubSub from 'pub-sub-es'; import createLine from 'regl-line'; import createKdbush from './kdbush.js'; import createLassoManager from './lasso-manager/index.js'; import createRenderer from './renderer.js'; import BG_FS from './bg.fs'; import BG_VS from './bg.vs'; import POINT_SIMPLE_FS from './point-simple.fs'; import POINT_UPDATE_FS from './point-update.fs'; import POINT_UPDATE_VS from './point-update.vs'; import POINT_FS from './point.fs'; import createVertexShader from './point.vs'; import createSplineCurve from './spline-curve.js'; import { AUTO, CATEGORICAL, COLOR_ACTIVE_IDX, COLOR_BG_IDX, COLOR_HOVER_IDX, COLOR_NORMAL_IDX, COLOR_NUM_STATES, CONTINUOUS, DEFAULT_ACTION_KEY_MAP, DEFAULT_ANNOTATION_HVLINE_LIMIT, DEFAULT_ANNOTATION_LINE_COLOR, DEFAULT_ANNOTATION_LINE_WIDTH, DEFAULT_ANTI_ALIASING, DEFAULT_BACKGROUND_IMAGE, DEFAULT_CAMERA_IS_FIXED, DEFAULT_COLOR_ACTIVE, DEFAULT_COLOR_BG, DEFAULT_COLOR_BY, DEFAULT_COLOR_HOVER, DEFAULT_COLOR_NORMAL, DEFAULT_DATA_ASPECT_RATIO, DEFAULT_DESELECT_ON_DBL_CLICK, DEFAULT_DESELECT_ON_ESCAPE, DEFAULT_DISTANCE, DEFAULT_EASING, DEFAULT_HEIGHT, DEFAULT_IMAGE_LOAD_TIMEOUT, DEFAULT_LASSO_BRUSH_SIZE, DEFAULT_LASSO_CLEAR_EVENT, DEFAULT_LASSO_COLOR, DEFAULT_LASSO_INITIATOR, DEFAULT_LASSO_LINE_WIDTH, DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME, DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY, DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME, DEFAULT_LASSO_LONG_PRESS_TIME, DEFAULT_LASSO_MIN_DELAY, DEFAULT_LASSO_MIN_DIST, DEFAULT_LASSO_ON_LONG_PRESS, DEFAULT_LASSO_TYPE, DEFAULT_MOUSE_MODE, DEFAULT_OPACITY_BY, DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME, DEFAULT_OPACITY_BY_DENSITY_FILL, DEFAULT_OPACITY_INACTIVE_MAX, DEFAULT_OPACITY_INACTIVE_SCALE, DEFAULT_PERFORMANCE_MODE, DEFAULT_PIXEL_ALIGNED, DEFAULT_POINT_CONNECTION_COLOR_ACTIVE, DEFAULT_POINT_CONNECTION_COLOR_BY, DEFAULT_POINT_CONNECTION_COLOR_HOVER, DEFAULT_POINT_CONNECTION_COLOR_NORMAL, DEFAULT_POINT_CONNECTION_INT_POINTS_TOLERANCE, DEFAULT_POINT_CONNECTION_MAX_INT_POINTS_PER_SEGMENT, DEFAULT_POINT_CONNECTION_OPACITY, DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE, DEFAULT_POINT_CONNECTION_OPACITY_BY, DEFAULT_POINT_CONNECTION_SIZE, 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, DEFAULT_RETICLE_COLOR, DEFAULT_ROTATION, DEFAULT_SHOW_POINT_CONNECTIONS, DEFAULT_SHOW_RETICLE, DEFAULT_SIZE_BY, DEFAULT_SPATIAL_INDEX_USE_WORKER, DEFAULT_TARGET, DEFAULT_VIEW, DEFAULT_WIDTH, EASING_FNS, ERROR_INSTANCE_IS_DESTROYED, ERROR_IS_DRAWING, ERROR_POINTS_NOT_DRAWN, FLOAT_BYTES, KEYS, KEY_ACTIONS, KEY_ACTION_LASSO, KEY_ACTION_MERGE, KEY_ACTION_REMOVE, KEY_ACTION_ROTATE, KEY_ALT, KEY_CMD, KEY_CTRL, KEY_META, KEY_SHIFT, LASSO_BRUSH_MIN_MIN_DIST, LASSO_CLEAR_EVENTS, LASSO_CLEAR_ON_DESELECT, LASSO_CLEAR_ON_END, LONG_CLICK_TIME, MIN_POINT_SIZE, MOUSE_MODES, MOUSE_MODE_LASSO, MOUSE_MODE_PANZOOM, MOUSE_MODE_ROTATE, SINGLE_CLICK_DELAY, SKIP_DEPRECATION_VALUE_TRANSLATION, VALUE_ZW_DATA_TYPES, W_NAMES, Z_NAMES, } from './constants.js'; import { checkReglExtensions as checkSupport, clip, createRegl, createTextureFromUrl, dist, flipObj, getBBox, insertionSort, isConditionalArray, isDomRect, isHorizontalLine, isMultipleColors, isPointInPolygon, isPolygon, isPositiveNumber, isRect, isSameRgbas, isStrictlyPositiveNumber, isString, isValidBBox, isVerticalLine, limit, max, min, rgbBrightness, toArrayOrientedPoints, toRgba, } from './utils.js'; import { version } from '../package.json'; const deprecations = { showRecticle: { replacement: 'showReticle', removalVersion: '2', translation: identity, }, recticleColor: { replacement: 'reticleColor', removalVersion: '2', translation: identity, }, keyMap: { replacement: 'actionKeyMap', removalVersion: '2', translation: flipObj, }, }; const checkDeprecations = (properties) => { const deprecatedProps = Object.keys(properties).filter( (prop) => deprecations[prop], ); for (const prop of deprecatedProps) { const { replacement, removalVersion, translation } = deprecations[prop]; // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning console.warn( `regl-scatterplot: the "${prop}" property is deprecated and will be removed in v${removalVersion}. Please use "${replacement}" instead.`, ); properties[deprecations[prop].replacement] = properties[prop] !== SKIP_DEPRECATION_VALUE_TRANSLATION ? translation(properties[prop]) : properties[prop]; delete properties[prop]; } return properties; }; const getEncodingType = ( type, defaultValue, { allowSegment = false, allowDensity = false, allowInherit = false } = {}, ) => { // Z refers to the 3rd component of the RGBA value if (Z_NAMES.has(type)) { return 'valueZ'; } // W refers to the 4th component of the RGBA value if (W_NAMES.has(type)) { return 'valueW'; } if (type === 'segment') { return allowSegment ? 'segment' : defaultValue; } if (type === 'density') { return allowDensity ? 'density' : defaultValue; } if (type === 'inherit') { return allowInherit ? 'inherit' : defaultValue; } return defaultValue; }; const getEncodingIdx = (type) => { switch (type) { case 'valueZ': return 2; case 'valueW': return 3; default: return null; } }; const createScatterplot = ( /** @type {Partial<import('./types').Properties>} */ initialProperties = {}, ) => { /** @type {import('pub-sub-es').PubSub<import('./types').Events>} */ const pubSub = createPubSub({ async: !initialProperties.syncEvents, caseInsensitive: true, }); const scratch = new Float32Array(16); const pvm = new Float32Array(16); const mousePosition = [0, 0]; checkDeprecations(initialProperties); let { renderer, antiAliasing = DEFAULT_ANTI_ALIASING, pixelAligned = DEFAULT_PIXEL_ALIGNED, 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, lassoLongPressIndicatorParentElement = 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, lassoType = DEFAULT_LASSO_TYPE, lassoBrushSize = DEFAULT_LASSO_BRUSH_SIZE, actionKeyMap = DEFAULT_ACTION_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, pointScaleMode = DEFAULT_POINT_SCALE_MODE, height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH, annotationLineColor = DEFAULT_ANNOTATION_LINE_COLOR, annotationLineWidth = DEFAULT_ANNOTATION_LINE_WIDTH, annotationHVLineLimit = DEFAULT_ANNOTATION_HVLINE_LIMIT, cameraIsFixed = DEFAULT_CAMERA_IS_FIXED, } = initialProperties; let currentWidth = width === AUTO ? 1 : width; let currentHeight = height === AUTO ? 1 : height; // The following properties cannot be changed after the initialization const { performanceMode = DEFAULT_PERFORMANCE_MODE, opacityByDensityDebounceTime = DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME, spatialIndexUseWorker = DEFAULT_SPATIAL_INDEX_USE_WORKER, } = initialProperties; const renderPointsAsSquares = Boolean( initialProperties.renderPointsAsSquares || performanceMode, ); const disableAlphaBlending = Boolean( initialProperties.disableAlphaBlending || performanceMode, ); mouseMode = limit(MOUSE_MODES, MOUSE_MODE_PANZOOM)(mouseMode); if (!renderer) { renderer = createRenderer({ regl: initialProperties.regl, gamma: initialProperties.gamma, }); } backgroundColor = toRgba(backgroundColor, true); lassoColor = toRgba(lassoColor, true); reticleColor = toRgba(reticleColor, true); let isDrawing = false; let isDestroyed = false; let backgroundColorBrightness = rgbBrightness(backgroundColor); let camera; /** @type {ReturnType<createLine>} */ let lasso; /** @type {ReturnType<createLine>} */ let annotations; let mouseDown = false; let mouseDownTime = null; let mouseDownPosition = [0, 0]; let mouseDownTimeout = -1; /** @type{number[]} */ let selectedPoints = []; /** @type{Set<number>} */ const selectedPointsSet = new Set(); /** @type{Set<number>} */ const selectedPointsConnectionSet = new Set(); let isPointsFiltered = false; /** @type{Set<number>} */ const filteredPointsSet = new Set(); let points = []; let numPoints = 0; let numPointsInView = 0; let lassoActive = false; let lassoPointsCurr = []; let spatialIndex; let viewAspectRatio; let dataAspectRatio = initialProperties.aspectRatio || DEFAULT_DATA_ASPECT_RATIO; let projectionLocal; let projection; let model; let pointConnections; let pointConnectionMap; let computingPointConnectionCurves; // biome-ignore lint/style/useNamingConvention: HLine stands for HorizontalLine let reticleHLine; // biome-ignore lint/style/useNamingConvention: VLine stands for VerticalLine let reticleVLine; let computedPointSizeMouseDetection; let lassoInitiatorTimeout; let topRightNdc; let bottomLeftNdc; let preventEventView = false; let draw = true; let drawReticleOnce = false; let canvasObserver; pointColor = isMultipleColors(pointColor) ? [...pointColor] : [pointColor]; pointColorActive = isMultipleColors(pointColorActive) ? [...pointColorActive] : [pointColorActive]; pointColorHover = isMultipleColors(pointColorHover) ? [...pointColorHover] : [pointColorHover]; pointColor = pointColor.map((color) => toRgba(color, true)); pointColorActive = pointColorActive.map((color) => toRgba(color, true)); pointColorHover = pointColorHover.map((color) => toRgba(color, true)); opacity = !Array.isArray(opacity) && Number.isNaN(+opacity) ? pointColor[0][3] : opacity; opacity = isConditionalArray(opacity, isPositiveNumber, { minLength: 1, }) ? [...opacity] : [opacity]; pointSize = isConditionalArray(pointSize, isPositiveNumber, { minLength: 1, }) ? [...pointSize] : [pointSize]; let minPointScale = MIN_POINT_SIZE / pointSize[0]; if (pointConnectionColor === 'inherit') { pointConnectionColor = [...pointColor]; } else { pointConnectionColor = isMultipleColors(pointConnectionColor) ? [...pointConnectionColor] : [pointConnectionColor]; pointConnectionColor = pointConnectionColor.map((color) => toRgba(color, true), ); } if (pointConnectionColorActive === 'inherit') { pointConnectionColorActive = [...pointColorActive]; } else { pointConnectionColorActive = isMultipleColors(pointConnectionColorActive) ? [...pointConnectionColorActive] : [pointConnectionColorActive]; pointConnectionColorActive = pointConnectionColorActive.map((color) => toRgba(color, true), ); } if (pointConnectionColorHover === 'inherit') { pointConnectionColorHover = [...pointColorHover]; } else { pointConnectionColorHover = isMultipleColors(pointConnectionColorHover) ? [...pointConnectionColorHover] : [pointConnectionColorHover]; pointConnectionColorHover = pointConnectionColorHover.map((color) => toRgba(color, true), ); } if (pointConnectionOpacity === 'inherit') { pointConnectionOpacity = [...opacity]; } else { pointConnectionOpacity = isConditionalArray( pointConnectionOpacity, isPositiveNumber, { minLength: 1, }, ) ? [...pointConnectionOpacity] : [pointConnectionOpacity]; } if (pointConnectionSize === 'inherit') { pointConnectionSize = [...pointSize]; } else { pointConnectionSize = isConditionalArray( pointConnectionSize, isPositiveNumber, { minLength: 1, }, ) ? [...pointConnectionSize] : [pointConnectionSize]; } colorBy = getEncodingType(colorBy, DEFAULT_COLOR_BY); opacityBy = getEncodingType(opacityBy, DEFAULT_OPACITY_BY, { allowDensity: true, }); sizeBy = getEncodingType(sizeBy, DEFAULT_SIZE_BY); pointConnectionColorBy = getEncodingType( pointConnectionColorBy, DEFAULT_POINT_CONNECTION_COLOR_BY, { allowSegment: true, allowInherit: true }, ); pointConnectionOpacityBy = getEncodingType( pointConnectionOpacityBy, DEFAULT_POINT_CONNECTION_OPACITY_BY, { allowSegment: true }, ); pointConnectionSizeBy = getEncodingType( pointConnectionSizeBy, DEFAULT_POINT_CONNECTION_SIZE_BY, { allowSegment: true }, ); let stateTex; // Stores the point texture holding x, y, category, and value let prevStateTex; // Stores the previous point texture. Used for transitions let tmpStateTex; // Stores a temporary point texture. Used for transitions let tmpStateBuffer; // Temporary frame buffer let stateTexRes = 0; // Width and height of the texture let stateTexEps = 0; // Half a texel let normalPointsIndexBuffer; // Buffer holding the indices pointing to the correct texel let selectedPointsIndexBuffer; // Used for pointing to the selected texels let hoveredPointIndexBuffer; // Used for pointing to the hovered texels let cameraZoomTargetStart; // Stores the start (i.e., current) camera target for zooming let cameraZoomTargetEnd; // Stores the end camera target for zooming let cameraZoomDistanceStart; // Stores the start camera distance for zooming let cameraZoomDistanceEnd; // Stores the end camera distance for zooming let isTransitioning = false; let transitionStartTime = null; let transitionDuration; let transitionEasing; let preTransitionShowReticle = showReticle; let colorTex; // Stores the point color texture let colorTexRes = 0; // Width and height of the texture let encodingTex; // Stores the point sizes and opacity values let encodingTexRes = 0; // Width and height of the texture let isViewChanged = false; let isPointsDrawn = false; let isAnnotationsDrawn = false; let isMouseOverCanvasChecked = false; // biome-ignore lint/style/useNamingConvention: ZDate is not one word let valueZDataType = CATEGORICAL; // biome-ignore lint/style/useNamingConvention: WDate is not one word let valueWDataType = CATEGORICAL; /** @type{number|undefined} */ let hoveredPoint; let isMouseInCanvas = false; let xScale = initialProperties.xScale || null; let yScale = initialProperties.yScale || null; let xDomainStart = 0; let xDomainSize = 0; let yDomainStart = 0; let yDomainSize = 0; if (xScale) { xDomainStart = xScale.domain()[0]; xDomainSize = xScale.domain()[1] - xScale.domain()[0]; xScale.range([0, currentWidth]); } if (yScale) { yDomainStart = yScale.domain()[0]; yDomainSize = yScale.domain()[1] - yScale.domain()[0]; yScale.range([currentHeight, 0]); } const getNdcX = (x) => -1 + (x / currentWidth) * 2; const getNdcY = (y) => 1 + (y / currentHeight) * -2; // Get relative WebGL position const getMouseGlPos = () => [ getNdcX(mousePosition[0]), getNdcY(mousePosition[1]), ]; const getScatterGlPos = (xGl, yGl) => { // Homogeneous vector const v = [xGl, yGl, 1, 1]; // projection^-1 * view^-1 * model^-1 is the same as // model * view^-1 * projection const mvp = mat4.invert( scratch, mat4.multiply( scratch, projectionLocal, mat4.multiply(scratch, camera.view, model), ), ); // Translate vector vec4.transformMat4(v, v, mvp); return v.slice(0, 2); }; const getPointSizeNdc = (pointSizeIncrease = 0) => { const pointScale = getPointScale(); // The height of the view in normalized device coordinates const heightNdc = topRightNdc[1] - bottomLeftNdc[1]; // The size of a pixel in the current view in normalized device coordinates const pxNdc = heightNdc / canvas.height; // The scaled point size in normalized device coordinates return ( (computedPointSizeMouseDetection * pointScale + pointSizeIncrease) * pxNdc ); }; const getPoints = () => { if (isPointsFiltered) { return points.filter((_, i) => filteredPointsSet.has(i)); } return points; }; // biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox const getPointsInBBox = (x0, y0, x1, y1) => { // biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox const pointsInBBox = spatialIndex.range(x0, y0, x1, y1); if (isPointsFiltered) { return pointsInBBox.filter((i) => filteredPointsSet.has(i)); } return pointsInBBox; }; const raycast = () => { const [xGl, yGl] = getMouseGlPos(); const [xNdc, yNdc] = getScatterGlPos(xGl, yGl); const pointSizeNdc = getPointSizeNdc(4); // Get all points within a close range // biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox const pointsInBBox = getPointsInBBox( xNdc - pointSizeNdc, yNdc - pointSizeNdc, xNdc + pointSizeNdc, yNdc + pointSizeNdc, ); // Find the closest point let minDist = pointSizeNdc; let clostestPointIdx = -1; for (const pointIdx of pointsInBBox) { const [ptX, ptY] = points[pointIdx]; const d = dist(ptX, ptY, xNdc, yNdc); if (d < minDist) { minDist = d; clostestPointIdx = pointIdx; } } return clostestPointIdx; }; const lassoExtend = (lassoPoints, lassoPointsFlat) => { lassoPointsCurr = lassoPoints; lasso.setPoints(lassoPointsFlat); pubSub.publish('lassoExtend', { coordinates: lassoPoints }); }; const findPointsInLasso = (lassoPolygon) => { // get the bounding box of the lasso selection... const bBox = getBBox(lassoPolygon); if (!isValidBBox(bBox)) { return []; } // ...to efficiently preselect potentially selected points // biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox const pointsInBBox = getPointsInBBox(...bBox); // next we test each point in the bounding box if it is in the polygon too const pointsInPolygon = []; for (const pointIdx of pointsInBBox) { if (isPointInPolygon(lassoPolygon, points[pointIdx])) { pointsInPolygon.push(pointIdx); } } return pointsInPolygon; }; const lassoClear = () => { lassoPointsCurr = []; if (lasso) { lasso.clear(); } }; const hasPointConnections = (point) => point && point.length > 4; const setPointConnectionColorState = (pointIdxs, stateIndex) => { if ( computingPointConnectionCurves || !showPointConnections || !hasPointConnections(points[pointIdxs[0]]) ) { return; } const isNormal = stateIndex === 0; const lineIdCacher = stateIndex === 1 ? (lineId) => selectedPointsConnectionSet.add(lineId) : identity; // Get line IDs const lineIds = Object.keys( pointIdxs.reduce((ids, pointIdx) => { const point = points[pointIdx]; const isStruct = Array.isArray(point[4]); const lineId = isStruct ? point[4][0] : point[4]; ids[lineId] = true; return ids; }, {}), ); const buffer = pointConnections.getData().opacities; const unselectedLineIds = lineIds.filter( (lineId) => !selectedPointsConnectionSet.has(+lineId), ); for (const lineId of unselectedLineIds) { const index = pointConnectionMap[lineId][0]; const numPointPerLine = pointConnectionMap[lineId][2]; const pointOffset = pointConnectionMap[lineId][3]; const bufferStart = index * 4 + pointOffset * 2; const bufferEnd = bufferStart + numPointPerLine * 2 + 4; if (buffer.__original__ === undefined) { buffer.__original__ = buffer.slice(); } for (let i = bufferStart; i < bufferEnd; i++) { // buffer[i] = Math.floor(buffer[i] / 4) * 4 + stateIndex; buffer[i] = isNormal ? buffer.__original__[i] : pointConnectionOpacityActive; } lineIdCacher(lineId); } pointConnections.getBuffer().opacities.subdata(buffer, 0); }; const indexToStateTexCoord = (index) => [ (index % stateTexRes) / stateTexRes + stateTexEps, Math.floor(index / stateTexRes) / stateTexRes + stateTexEps, ]; const isPointsFilteredOut = (pointIdx) => isPointsFiltered && !filteredPointsSet.has(pointIdx); const deselect = ({ preventEvent = false } = {}) => { if (lassoClearEvent === LASSO_CLEAR_ON_DESELECT) { lassoClear(); } if (selectedPoints.length > 0) { if (!preventEvent) { pubSub.publish('deselect'); } selectedPointsConnectionSet.clear(); setPointConnectionColorState(selectedPoints, 0); selectedPoints = []; selectedPointsSet.clear(); draw = true; } }; /** * Select and highlight a set of points * @param {number | number[]} pointIdxs * @param {import('./types').ScatterplotMethodOptions['select']} */ const select = ( pointIdxs, { merge = false, remove = false, preventEvent = false } = {}, ) => { const newSelectedPoints = Array.isArray(pointIdxs) ? pointIdxs : [pointIdxs]; const currSelectedPoints = [...selectedPoints]; if (merge) { selectedPoints = unionIntegers(selectedPoints, newSelectedPoints); if (currSelectedPoints.length === selectedPoints.length) { draw = true; return; } } else if (remove) { const newSelectedPointsSet = new Set(newSelectedPoints); selectedPoints = selectedPoints.filter( (point) => !newSelectedPointsSet.has(point), ); if (currSelectedPoints.length === selectedPoints.length) { draw = true; return; } } else { // Unset previously highlight point connections if (selectedPoints?.length > 0) { setPointConnectionColorState(selectedPoints, 0); } if (currSelectedPoints.length > 0 && newSelectedPoints.length === 0) { deselect({ preventEvent }); return; } selectedPoints = newSelectedPoints; } if (hasSameElements(currSelectedPoints, selectedPoints)) { draw = true; return; } const selectedPointsBuffer = []; selectedPointsSet.clear(); selectedPointsConnectionSet.clear(); for (let i = selectedPoints.length - 1; i >= 0; i--) { const pointIdx = selectedPoints[i]; if ( pointIdx < 0 || pointIdx >= numPoints || isPointsFilteredOut(pointIdx) ) { // Remove invalid selected points selectedPoints.splice(i, 1); continue; } selectedPointsSet.add(pointIdx); selectedPointsBuffer.push.apply( selectedPointsBuffer, indexToStateTexCoord(pointIdx), ); } selectedPointsIndexBuffer({ usage: 'dynamic', type: 'float', data: selectedPointsBuffer, }); setPointConnectionColorState(selectedPoints, 1); if (!preventEvent) { pubSub.publish('select', { points: selectedPoints }); } draw = true; }; /** * @param {number} point * @param {import('./types').ScatterplotMethodOptions['hover']} options */ const hover = ( point, { showReticleOnce = false, preventEvent = false } = {}, ) => { let needsRedraw = false; const isFilteredOut = isPointsFiltered && !filteredPointsSet.has(point); if (!isFilteredOut && point >= 0 && point < numPoints) { needsRedraw = true; const oldHoveredPoint = hoveredPoint; const newHoveredPoint = point !== hoveredPoint; if ( +oldHoveredPoint >= 0 && newHoveredPoint && !selectedPointsSet.has(oldHoveredPoint) ) { setPointConnectionColorState([oldHoveredPoint], 0); } hoveredPoint = point; hoveredPointIndexBuffer.subdata(indexToStateTexCoord(point)); if (!selectedPointsSet.has(point)) { setPointConnectionColorState([point], 2); } if (newHoveredPoint && !preventEvent) { pubSub.publish('pointover', hoveredPoint); } } else { needsRedraw = +hoveredPoint >= 0; if (needsRedraw) { if (!selectedPointsSet.has(hoveredPoint)) { setPointConnectionColorState([hoveredPoint], 0); } if (!preventEvent) { pubSub.publish('pointout', hoveredPoint); } } hoveredPoint = undefined; } if (needsRedraw) { draw = true; drawReticleOnce = showReticleOnce; } }; const getRelativeMousePosition = (event) => { const rect = canvas.getBoundingClientRect(); mousePosition[0] = event.clientX - rect.left; mousePosition[1] = event.clientY - rect.top; return [...mousePosition]; }; const lassoStart = () => { // Fix camera for the lasso selection camera.config({ isFixed: true }); mouseDown = true; lassoActive = true; lassoClear(); if (mouseDownTimeout >= 0) { clearTimeout(mouseDownTimeout); mouseDownTimeout = -1; } pubSub.publish('lassoStart'); }; const lassoEnd = ( lassoPoints, lassoPointsFlat, { merge = false, remove = false } = {}, ) => { camera.config({ isFixed: cameraIsFixed }); lassoPointsCurr = [...lassoPoints]; const pointsInLasso = findPointsInLasso(lassoPointsFlat); select(pointsInLasso, { merge, remove }); pubSub.publish('lassoEnd', { coordinates: lassoPointsCurr, }); if (lassoClearEvent === LASSO_CLEAR_ON_END) { lassoClear(); } }; const lassoManager = createLassoManager(canvas, { onStart: lassoStart, onDraw: lassoExtend, onEnd: lassoEnd, enableInitiator: lassoInitiator, initiatorParentElement: lassoInitiatorParentElement, longPressIndicatorParentElement: lassoLongPressIndicatorParentElement, pointNorm: ([x, y]) => getScatterGlPos(getNdcX(x), getNdcY(y)), minDelay: lassoMinDelay, minDist: lassoType === 'brush' ? Math.max(LASSO_BRUSH_MIN_MIN_DIST, lassoMinDist) : lassoMinDist, type: lassoType, }); const checkLassoMode = () => mouseMode === MOUSE_MODE_LASSO; const checkModKey = (event, action) => { switch (actionKeyMap[action]) { case KEY_ALT: return event.altKey; case KEY_CMD: return event.metaKey; case KEY_CTRL: return event.ctrlKey; case KEY_META: return event.metaKey; case KEY_SHIFT: return event.shiftKey; default: return false; } }; const checkIfMouseIsOverCanvas = (event) => document .elementsFromPoint(event.clientX, event.clientY) .some((element) => element === canvas); const mouseDownHandler = (event) => { if (!isPointsDrawn || event.buttons !== 1) { return; } mouseDown = true; mouseDownTime = performance.now(); mouseDownPosition = getRelativeMousePosition(event); lassoActive = checkLassoMode() || checkModKey(event, KEY_ACTION_LASSO); if (!lassoActive && lassoOnLongPress) { lassoManager.showLongPressIndicator(event.clientX, event.clientY, { time: lassoLongPressTime, extraTime: lassoLongPressAfterEffectTime, delay: lassoLongPressEffectDelay, }); mouseDownTimeout = setTimeout(() => { mouseDownTimeout = -1; lassoActive = true; }, lassoLongPressTime); } }; const mouseUpHandler = (event) => { if (!isPointsDrawn) { return; } mouseDown = false; if (mouseDownTimeout >= 0) { clearTimeout(mouseDownTimeout); mouseDownTimeout = -1; } if (lassoActive) { event.preventDefault(); lassoActive = false; lassoManager.end({ merge: checkModKey(event, KEY_ACTION_MERGE), remove: checkModKey(event, KEY_ACTION_REMOVE), }); } if (lassoOnLongPress) { lassoManager.hideLongPressIndicator({ time: lassoLongPressRevertEffectTime, }); } }; const mouseClickHandler = (event) => { if (!isPointsDrawn) { return; } event.preventDefault(); const currentMousePosition = getRelativeMousePosition(event); if (dist(...currentMousePosition, ...mouseDownPosition) >= lassoMinDist) { return; } const clickTime = performance.now() - mouseDownTime; if (!lassoInitiator || clickTime < LONG_CLICK_TIME) { // If the user clicked normally (i.e., fast) we'll only show the lasso // initiator if the use click into the void const clostestPoint = raycast(); if (clostestPoint >= 0) { if ( selectedPoints.length > 0 && lassoClearEvent === LASSO_CLEAR_ON_DESELECT ) { // Special case where we silently "deselect" the previous points by // overriding the selected points. Hence, we need to clear the lasso. lassoClear(); } select([clostestPoint], { merge: checkModKey(event, KEY_ACTION_MERGE), remove: checkModKey(event, KEY_ACTION_REMOVE), }); } else if (!lassoInitiatorTimeout) { // We'll also wait to make sure the user didn't double click lassoInitiatorTimeout = setTimeout(() => { lassoInitiatorTimeout = null; lassoManager.showInitiator(event); }, SINGLE_CLICK_DELAY); } } }; const mouseDblClickHandler = (event) => { lassoManager.hideInitiator(); if (lassoInitiatorTimeout) { clearTimeout(lassoInitiatorTimeout); lassoInitiatorTimeout = null; } if (deselectOnDblClick) { event.preventDefault(); deselect(); } }; const mouseMoveHandler = (event) => { if (!isMouseOverCanvasChecked) { isMouseInCanvas = checkIfMouseIsOverCanvas(event); isMouseOverCanvasChecked = true; } if (!(isPointsDrawn && (isMouseInCanvas || mouseDown))) { return; } const currentMousePosition = getRelativeMousePosition(event); const mouseMoveDist = dist(...currentMousePosition, ...mouseDownPosition); const mouseMovedMin = mouseMoveDist >= lassoMinDist; // Only ray cast if the mouse cursor is inside if (isMouseInCanvas && !lassoActive) { hover(raycast()); // eslint-disable-line no-use-before-define } if (lassoActive) { event.preventDefault(); lassoManager.extend(event, true); } else if (mouseDown && lassoOnLongPress && mouseMovedMin) { lassoManager.hideLongPressIndicator({ time: lassoLongPressRevertEffectTime, }); } if (mouseDownTimeout >= 0 && mouseMovedMin) { clearTimeout(mouseDownTimeout); mouseDownTimeout = -1; } // Always redraw when mousedown as the user might have panned or lassoed if (mouseDown) { draw = true; } }; const blurHandler = () => { hoveredPoint = undefined; isMouseInCanvas = false; isMouseOverCanvasChecked = false; if (!isPointsDrawn) { return; } if (+hoveredPoint >= 0 && !selectedPointsSet.has(hoveredPoint)) { setPointConnectionColorState([hoveredPoint], 0); } mouseUpHandler(); draw = true; }; const createEncodingTexture = () => { const maxEncoding = Math.max(pointSize.length, opacity.length); encodingTexRes = Math.max(2, Math.ceil(Math.sqrt(maxEncoding))); const rgba = new Float32Array(encodingTexRes ** 2 * 4); for (let i = 0; i < maxEncoding; i++) { rgba[i * 4] = pointSize[i] || 0; rgba[i * 4 + 1] = Math.min(1, opacity[i] || 0); const activeOpacity = Number( (pointColorActive[i] || pointColorActive[0])[3], ); rgba[i * 4 + 2] = Math.min( 1, Number.isNaN(activeOpacity) ? 1 : activeOpacity, ); const hoverOpacity = Number( (pointColorHover[i] || pointColorHover[0])[3], ); rgba[i * 4 + 3] = Math.min( 1, Number.isNaN(hoverOpacity) ? 1 : hoverOpacity, ); } return renderer.regl.texture({ data: rgba, shape: [encodingTexRes, encodingTexRes, 4], type: 'float', }); }; const getColors = ( baseColor = pointColor, activeColor = pointColorActive, hoverColor = pointColorHover, ) => { const n = baseColor.length; const n2 = activeColor.length; const n3 = hoverColor.length; const colors = []; if (n === n2 && n2 === n3) { for (let i = 0; i < n; i++) { colors.push( baseColor[i], activeColor[i], hoverColor[i], backgroundColor, ); } } else { for (let i = 0; i < n; i++) { const rgbaOpaque = [ baseColor[i][0], baseColor[i][1], baseColor[i][2], 1, ]; const colorActive = colorBy === DEFAULT_COLOR_BY ? activeColor[0] : rgbaOpaque; const colorHover = colorBy === DEFAULT_COLOR_BY ? hoverColor[0] : rgbaOpaque; colors.push(baseColor[i], colorActive, colorHover, backgroundColor); } } return colors; }; const createColorTexture = () => { const colors = getColors(); const numColors = colors.length; colorTexRes = Math.max(2, Math.ceil(Math.sqrt(numColors))); const rgba = new Float32Array(colorTexRes ** 2 * 4); colors.forEach((color, i) => { rgba[i * 4] = color[0]; // r rgba[i * 4 + 1] = color[1]; // g rgba[i * 4 + 2] = color[2]; // b rgba[i * 4 + 3] = color[3]; // a }); return renderer.regl.texture({ data: rgba, shape: [colorTexRes, colorTexRes, 4], type: 'float', }); }; /** * Since we're using an external renderer whose canvas' width and height * might differ from this instance's width and height, we have to adjust the * projection of camera spaces into clip space accordingly. * * The `widthRatio` is rendererCanvas.width / thisCanvas.width * The `heightRatio` is rendererCanvas.height / thisCanvas.height */ const updateProjectionMatrix = (widthRatio, heightRatio) => { projection[0] = widthRatio / viewAspectRatio; projection[5] = heightRatio; }; const updateViewAspectRatio = () => { viewAspectRatio = currentWidth / currentHeight; projectionLocal = mat4.fromScaling([], [1 / viewAspectRatio, 1, 1]); projection = mat4.fromScaling([], [1 / viewAspectRatio, 1, 1]); model = mat4.fromScaling([], [dataAspectRatio, 1, 1]); }; const setDataAspectRatio = (newDataAspectRatio) => { if (+newDataAspectRatio <= 0) { return; } dataAspectRatio = newDataAspectRatio; }; const setColors = (getter, setter) => (newColors) => { if (!newColors || newColors.length === 0) { return; } const colors = getter(); const prevColors = [...colors]; let tmpColors = isMultipleColors(newColors) ? newColors : [newColors]; tmpColors = tmpColors.map((color) => toRgba(color, true)); if (isSameRgbas(prevColors, tmpColors)) { // We don't need to update the color texture so we return early return; } if (colorTex) { colorTex.destroy(); } try { setter(tmpColors); colorTex = createColorTexture(); } catch (_error) { // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning console.error('Invalid colors. Switching back to default colors.'); setter(prevColors); colorTex = createColorTexture(); } }; const setPointColor = setColors( () => pointColor, (colors) => { pointColor = colors; }, ); const setPointColorActive = setColors( () => pointColorActive, (colors) => { pointColorActive = colors; }, ); const setPointColorHover = setColors( () => pointColorHover, (colors) => { pointColorHover = colors; }, ); const computeDomainView = () => { const xyStartPt = getScatterGlPos(-1, -1); const xyEndPt = getScatterGlPos(1, 1); const xStart = (xyStartPt[0] + 1) / 2; const xEnd = (xyEndPt[0] + 1) / 2; const yStart = (xyStartPt[1] + 1) / 2; const yEnd = (xyEndPt[1] + 1) / 2; const xDomainView = [ xDomainStart + xStart * xDomainSize, xDomainStart + xEnd * xDomainSize, ]; const yDomainView = [ yDomainStart + yStart * yDomainSize, yDomainStart + yEnd * yDomainSize, ]; return [xDomainView, yDomainView]; }; const updateScales = () => { if (!(xScale || yScale)) { return; } const [xDomainView, yDomainView] = computeDomainView(); if (xScale) { xScale.domain(xDomainView); } if (yScale) { yScale.domain(yDomainView); } }; const setCurrentHeight = (newCurrentHeight) => { currentHeight = Math.max(1, newCurrentHeight); canvas.height = Math.floor(currentHeight * window.devicePixelRatio); if (yScale) { yScale.range([currentHeight, 0]); updateScales(); } }; const setHeight = (newHeight) => { if (newHeight === AUTO) { height = newHeight; canvas.style.height = '100%'; window.requestAnimationFrame(() => { if (canvas) { setCurrentHeight(canvas.getBoundingClientRect().height); } }); return; } if (!+newHeight || +newHeight <= 0) { return; } height = +newHeight; setCurrentHeight(height); canvas.style.height = `${height}px`; }; const computePointSizeMouseDetection = () => { computedPointSizeMouseDetection = pointSizeMouseDetection; if (pointSizeMouseDetection === AUTO) { computedPointSizeMouseDetection = Array.isArray(pointSize) ? maxArray(pointSize) : pointSize; } }; const setPointSize = (newPointSize) => { const oldPointSize = Array.isArray(pointSize) ? [...pointSize] : pointSize; if (isConditionalArray(newPointSize, isPositiveNumber, { minLength: 1 })) { pointSize = [...newPointSize]; } else if (isStrictlyPositiveNumber(+newPointSize)) { pointSize = [+newPointSize]; } if ( oldPointSize === pointSize || hasSameElements(oldPointSize, pointSize) ) { // We don't need to update the encoding texture so we return early return; } if (encodingTex) { encodingTex.destroy(); } minPointScale = MIN_POINT_SIZE / pointSize[0]; encodingTex = createEncodingTexture(); computePointSizeMouseDetection(); }; const setPointSizeSelected = (newPointSizeSelected) => { if (!+newPointSizeSelected || +newPointSizeSelected < 0) { return; } pointSizeSelected = +newPointSizeSelected; }; const setPointOutlineWidth = (newPointOutlineWidth) => { if (!+newPointOutlineWidth || +newPointOutlineWidth < 0) { return; } pointOutlineWidth = +newPointOutlineWidth; }; const setCurrentWidth = (newCurrentWidth) => { currentWidth = Math.max(1, newCurrentWidth); canvas.width = Math.floor(currentWidth * window.devicePixelRatio); if (xScale) { xScale.range([0, currentWidth]); updateScales(); } }; const setWidth = (newWidth) => { if (newWidth === AUTO) { width = newWidth; canvas.style.width = '100%'; window.requestAnimationFrame(() => { if (canvas) { setCurrentWidth(canvas.getBoundingClientRect().width); } }); return; } if (!+newWidth || +newWidth <= 0) { return; } width = +newWidth; setCurrentWidth(width); canvas.style.width = `${currentWidth}px`; }; const setOpacity = (newOpacity) => { const oldOpacity = Array.isArray(opacity) ? [...opacity] : opacity; if (isConditionalArray(newOpacity, isPositiveNumber, { minLength: 1 })) { opacity = [...newOpacity]; } else if (isStrictlyPositiveNumber(+newOpacity)) { opacity = [+newOpacity]; } if (oldOpacity === opacity || hasSameElements(oldOpacity, opacity)) { // We don't need to update the encoding texture so we return early return; } if (encodingTex) { encodingTex.destroy(); } encodingTex = createEncodingTexture(); }; const getEncodingDataType = (type) => { switch (type) { case 'valueZ': return valueZDataType; case 'valueW': return valueWDataType; default: return null; } }; const getEncodingValueToIdx = (type, rangeValues) => { switch (type) { case CONTINUOUS: return (value) => Math.round(value * (rangeValues.length - 1)); default: return identity; } }; const setColorBy = (type) => { colorBy = getEncodingType(type, DEFAULT_COLOR_BY); }; const setOpacityBy = (type) => { opacityBy = getEncodingType(type, DEFAULT_OPACITY_BY, { allowDensity: true, }); }; const setSizeBy = (type) => { sizeBy = getEncodingType(type, DEFAULT_SIZE_BY); }; const setPointConnectionColorBy = (type) => { pointConnectionColorBy = getEncodingType( type, DEFAULT_POINT_CONNECTION_COLOR_BY, { allowSegment: true, allowInherit: true }, ); }; const setPointConnectionOpacityBy = (type) => { pointConnectionOpacityBy = getEncodingType( type, DEFAULT_POINT_CONNECTION_OPACITY_BY, { allowSegment: true }, ); }; const setPointConnectionSizeBy = (type) => { pointConnectionSizeBy = getEncodingType( type, DEFAULT_POINT_CONNECTION_SIZE_BY, { allowSegment: true }, ); }; const getAntiAliasing = () => antiAliasing; const getResolution = () => [canvas.width, canvas.height]; const getBackgroundImage = () => backgroundImage; const getColorTex = () => colorTex; const getColorTexRes = () => colorTexRes; const getColorTexEps = () => 0.5 / colorTexRes; const getDevicePixelRatio = () => window.devicePixelRatio; const getNormalPointsIndexBuffer = () => normalPointsIndexBuffer; const getSelectedPointsIndexBuffer = () => selectedPointsIndexBuffer; const getEncodingTex = () => encodingTex; const getEncodingTexRes = () => encodingTexRes; const getEncodingTexEps = () => 0.5 / encodingTexRes; const getNormalPointSizeExtra = () => 0; const getStateTex = () => tmpStateTex || stateTex; const getStateTexRes = () => stateTexRes; const getStateTexEps = () => 0.5 / stateTexRes; const getProjection = () => projection; const getView = () => camera.view; const getModel = () => model; const getModelViewProjection = () => mat4.multiply(pvm, projection, mat4.multiply(pvm, camera.view, model)); 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; const getPointOpacityMaxBase = () => getSelectedNumPoints() > 0 ? opacityInactiveMax : 1; const getPointOpacityScaleBase = () => getSelectedNumPoints() > 0 ? opacityInactiveScale : 1; const getIsColoredByZ = () => +(colorBy === 'valueZ'); const getIsColoredByW = () => +(colorBy === 'valueW'); const getIsOpacityByZ = () => +(opacityBy === 'valueZ'); const getIsOpacityByW = () => +(opacityBy === 'valueW'); const getIsOpacityByDensity = () => +(opacityBy === 'density'); const getIsSizedByZ = () => +(sizeBy === 'valueZ'); const getIsSizedByW = () => +(sizeBy === 'valueW'); const getIsPixelAligned = () => +pixelAligned; const getColorMultiplicator = () => { if (colorBy === 'valueZ') { return valueZDataType === CONTINUOUS ? pointColor.length - 1 : 1; } return valueWDataType === CONTINUOUS ? pointColor.length - 1 : 1; }; const getOpacityMultiplicator = () => { if (opacityBy === 'valueZ') { return valueZDataType === CONTINUOUS ? opacity.length - 1 : 1; } return valueWDataType === CONTINUOUS ? opacity.length - 1 : 1; }; const getSizeMultiplicator = () => { if (sizeBy === 'valueZ') { return valueZDataType === CONTINUOUS ? pointSize.length - 1 : 1; } return valueWDataType === CONTINUOUS ? pointSize.length - 1 : 1; }; const getOpacityDensity = (context) => { if (opacityBy !== 'density') { return 1; } // 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(); 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])); // Viewport size, in device pixels const H = context.viewportHeight; const W = context.viewportWidth; // 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); // Unless `renderPointsAsSquares` is true, we use circles, which only take // up (pi r^2) of the unit square alpha *= renderPointsAsSquares ? 1 : 1 / (0.25 * 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; // We square this since we're concerned with the ratio of *areas*. 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)); }; const updatePoints = renderer.regl({ framebuffer: () => tmpStateBuffer, vert: POINT_UPDATE_VS, frag: POINT_UPDATE_FS, attributes: { position: [-4, 0, 4, 4, 4, -4], }, uniforms: { startStateTex: () => prevStateTex, endStateTex: () => stateTex, t: (_ctx, props) => props.t, }, count: 3, }); const drawPoints = ( getPointSizeExtra, getNumPoints, getStateIndexBuffer, globalState = COLOR_NORMAL_IDX, getPointOpacityMax = getPointOpacityMaxBase, getPointOpacityScale = getPointOpacityScaleBase, ) => renderer.