regl-scatterplot
Version:
A WebGL-Powered Scalable Interactive Scatter Plot Library
1,702 lines (1,490 loc) • 129 kB
JavaScript
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.