UNPKG

regl-scatterplot

Version:

A WebGL-Powered Scalable Interactive Scatter Plot Library

4,612 lines • 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.regl({
      frag: renderPointsAsSquares ? POINT_SIMPLE_FS : POINT_FS,
      vert: createVertexShader(globalState),

      blend: {
        enable: !disableAlphaBlending,
        func: {
          // biome-ignore lint/style/useNamingConvention: Regl specific
          srcRGB: 'src alpha',
          srcAlpha: 'one',
          // biome-ignore lint/style/useNamingConvention: Regl specific
          dstRGB: 'one minus src alpha',
          dstAlpha: 'one minus src alpha',
        },
      },

      depth: { enable: false },

      attributes: {
        stateIndex: {
          buffer: getStateIndexBuffer,
          size: 2,
        },
      },

      uniforms: {
        antiAliasing: getAntiAliasing,
        resolution: getResolution,
        modelViewProjection: getModelViewProjection,
        devicePixelRatio: getDevicePixelRatio,
        pointScale: () => getPointScale(),
        encodingTex: getEncodingTex,
        encodingTexRes: getEncodingTexRes,
        encodingTexEps: getEncodingTexEps,
        pointOpacityMax: getPointOpacityMax,
        pointOpacityScale: getPointOpacityScale,
        pointSizeExtra: getPointSizeExtra,
        globalState,
        colorTex: getColorTex,
        colorTexRes: getColorTexRes,
        colorTexEps: getColorTexEps,
        stateTex: getStateTex,
        stateTexRes: getStateTexRes,
        stateTexEps: getStateTexEps,
        isColoredByZ: getIsColoredByZ,
        isColoredByW: getIsColoredByW,
        isOpacityByZ: getIsOpacityByZ,
        isOpacityByW: getIsOpacityByW,
        isOpacityByDensity: getIsOpacityByDensity,
        isSizedByZ: getIsSizedByZ,
        isSizedByW: getIsSizedByW,
        isPixelAligned: getIsPixelAligned,
        colorMultiplicator: getColorMultiplicator,
        opacityMultiplicator: getOpacityMultiplicator,
        opacityDensity: getOpacityDensity,
        sizeMultiplicator: getSizeMultiplicator,
        numColorStates: COLOR_NUM_STATES,
        drawingBufferWidth: (context) => context.drawingBufferWidth,
        drawingBufferHeight: (context) => context.drawingBufferHeight,
      },

      count: getNumPoints,

      primitive: 'points',
    });

  const drawPointBodies = drawPoints(
    getNormalPointSizeExtra,
    getNormalNumPoints,
    getNormalPointsIndexBuffer,
  );

  const drawHoveredPoint = drawPoints(
    getNormalPointSizeExtra,
    () => 1,
    () => hoveredPointIndexBuffer,
    COLOR_HOVER_IDX,
    () => 1,
    () => 1,
  );

  const drawSelectedPointOutlines = drawPoints(
    () => (pointSizeSelected + pointOutlineWidth * 2) * window.devicePixelRatio,
    getSelectedNumPoints,
    getSelectedPointsIndexBuffer,
    COLOR_ACTIVE_IDX,
    () => 1,
    () => 1,
  );

  const drawSelectedPointInnerBorder = drawPoints(
    () => (pointSizeSelected + pointOutlineWidth) * window.devicePixelRatio,
    getSelectedNumPoints,
    getSelectedPointsIndexBuffer,
    COLOR_BG_IDX,
    () => 1,
    () => 1,
  );

  const drawSelectedPointBodies = drawPoints(
    () => pointSizeSelected * window.devicePixelRatio,
    getSelectedNumPoints,
    getSelectedPointsIndexBuffer,
    COLOR_ACTIVE_IDX,
    () => 1,
    () => 1,
  );

  const drawSelectedPoints = () => {
    drawSelectedPointOutlines();
    drawSelectedPointInnerBorder();
    drawSelectedPointBodies();
  };

  const drawBackgroundImage = renderer.regl({
    frag: BG_FS,
    vert: BG_VS,

    attributes: {
      position: [0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0],
    },

    uniforms: {
      modelViewProjection: getModelViewProjection,
      texture: getBackgroundImage,
    },

    count: 6,
  });

  const drawLassoPolygon = renderer.regl({
    vert: `
      precision mediump float;
      uniform mat4 modelViewProjection;
      attribute vec2 position;
      void main () {
        gl_Position = modelViewProjection * vec4(position, 0, 1);
      }`,

    frag: `
      precision mediump float;
      uniform vec4 color;
      void main () {
        gl_FragColor = vec4(color.rgb, 0.2);
      }`,

    depth: { enable: false },

    blend: {
      enable: true,
      func: {
        // biome-ignore lint/style/useNamingConvention: Regl specific
        srcRGB: 'src alpha',
        srcAlpha: 'one',
        // biome-ignore lint/style/useNamingConvention: Regl specific
        dstRGB: 'one minus src alpha',
        dstAlpha: 'one minus src alpha',
      },
    },

    attributes: {
      position: () => lassoPointsCurr,
    },

    uniforms: {
      modelViewProjection: getModelViewProjection,
      color: () => lassoColor,
    },

    elements: () => earcut(lasso.getPoints()),
  });

  const drawReticle = () => {
    if (!(hoveredPoint >= 0)) {
      return;
    }

    const [x, y] = points[hoveredPoint].slice(0, 2);

    // Homogeneous coordinates of the point
    const v = [x, y, 0, 1];

    // We have to calculate the model-view-projection matrix outside of the
    // shader as we actually don't want the model, view, or projection of the
    // line view space to change such that the reticle is visualized across the
    // entire view container and not within the view of the scatterplot
    mat4.multiply(
      scratch,
      projection,
      mat4.multiply(scratch, camera.view, model),
    );

    vec4.transformMat4(v, v, scratch);

    reticleHLine.setPoints([-1, v[1], 1, v[1]]);
    reticleVLine.setPoints([v[0], 1, v[0], -1]);

    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) => {
    const index = new Float32Array(numNewPoints * 2);

    let j = 0;
    for (let i = 0; i < numNewPoints; ++i) {
      const texCoord = indexToStateTexCoord(i);
      index[j] = texCoord[0]; // x
      index[j + 1] = texCoord[1]; // y
      j += 2;
    }

    return index;
  };

  const createStateTexture = (newPoints, dataTypes = {}) => {
    const numNewPoints = newPoints.length;
    stateTexRes = Math.max(2, Math.ceil(Math.sqrt(numNewPoints)));
    stateTexEps = 0.5 / stateTexRes;
    const data = new Float32Array(stateTexRes ** 2 * 4);

    let zIsInts = true;
    let wIsInts = true;

    let k = 0;
    let z = 0;
    let w = 0;
    for (let i = 0; i < numNewPoints; ++i) {
      k = i * 4;

      data[k] = newPoints[i][0]; // x
      data[k + 1] = newPoints[i][1]; // y

      z = newPoints[i][2] || 0;
      w = newPoints[i][3] || 0;

      data[k + 2] = z; // z: value 1
      data[k + 3] = w; // w: value 2
      zIsInts &&= Number.isInteger(z);
      wIsInts &&= Number.isInteger(w);
    }

    if (dataTypes.z && VALUE_ZW_DATA_TYPES.includes(dataTypes.z)) {
      valueZDataType = dataTypes.z;
    } else {
      valueZDataType = zIsInts ? CATEGORICAL : CONTINUOUS;
    }

    if (dataTypes.w && VALUE_ZW_DATA_TYPES.includes(dataTypes.w)) {
      valueWDataType = dataTypes.w;
    } else {
      valueWDataType = wIsInts ? CATEGORICAL : CONTINUOUS;
    }

    return renderer.regl.texture({
      data,
      shape: [stateTexRes, stateTexRes, 4],
      type: 'float',
    });
  };

  const cachePoints = (newPoints, dataTypes = {}) => {
    if (!stateTex) {
      return false;
    }

    if (isTransitioning) {
      const tmp = prevStateTex;
      prevStateTex = tmpStateTex;
      tmp.destroy();
    } else {
      prevStateTex = stateTex;
    }

    tmpStateTex = createStateTexture(newPoints, dataTypes);
    tmpStateBuffer = renderer.regl.framebuffer({
      color: tmpStateTex,
      depth: false,
      stencil: false,
    });
    stateTex = undefined;

    return true;
  };

  const hasCachedPoints = () => Boolean(prevStateTex && tmpStateTex);

  const clearCachedPoints = () => {
    if (prevStateTex) {
      prevStateTex.destroy();
      prevStateTex = undefined;
    }

    if (tmpStateTex) {
      tmpStateTex.destroy();
      tmpStateTex = undefined;
    }
  };

  const setPoints = (newPoints, options = {}) =>
    new Promise((resolve) => {
      isPointsDrawn = false;

      const preventFilterReset =
        options?.preventFilterReset && newPoints.length === numPoints;

      numPoints = newPoints.length;
      numPointsInView = numPoints;

      if (stateTex) {
        stateTex.destroy();
      }
      stateTex = createStateTexture(newPoints, {
        z: options.zDataType,
        w: options.wDataType,
      });

      if (!preventFilterReset) {
        normalPointsIndexBuffer({
          usage: 'static',
          type: 'float',
          data: createPointIndex(numPoints),
        });
      }

      createKdbush(options.spatialIndex || newPoints, {
        useWorker: spatialIndexUseWorker,
      })
        .then((newSearchIndex) => {
          spatialIndex = newSearchIndex;
          points = newPoints;

          isPointsDrawn = true;
        })
        .then(resolve);
    });

  const cacheCamera = (newTarget, newDistance) => {
    cameraZoomTargetStart = camera.target;
    cameraZoomTargetEnd = newTarget;
    cameraZoomDistanceStart = camera.distance[0];
    cameraZoomDistanceEnd = newDistance;
  };

  const hasCachedCamera = () =>
    Boolean(
      cameraZoomTargetStart !== undefined &&
        cameraZoomTargetEnd !== undefined &&
        cameraZoomDistanceStart !== undefined &&
        cameraZoomDistanceEnd !== undefined,
    );

  const clearCachedCamera = () => {
    cameraZoomTargetStart = undefined;
    cameraZoomTargetEnd = undefined;
    cameraZoomDistanceStart = undefined;
    cameraZoomDistanceEnd = undefined;
  };

  const getPointConnectionColorIndices = (curvePoints) => {
    const colorEncoding =
      pointConnectionColorBy === 'inherit' ? colorBy : pointConnectionColorBy;

    if (colorEncoding === 'segment') {
      const maxColorIdx = pointConnectionColor.length - 1;
      if (maxColorIdx < 1) {
        return [];
      }
      return curvePoints.reduce((colorIndices, curve, index) => {
        let totalLength = 0;
        const segLengths = [];
        // Compute the total length of the line
        for (let i = 2; i < curve.length; i += 2) {
          const segLength = Math.sqrt(
            (curve[i - 2] - curve[i]) ** 2 + (curve[i - 1] - curve[i + 1]) ** 2,
          );
          segLengths.push(segLength);
          totalLength += segLength;
        }
        colorIndices[index] = [0];
        let cumLength = 0;
        // Assign the color index based on the cumulative length
        for (let i = 0; i < curve.length / 2 - 1; i++) {
          cumLength += segLengths[i];
          // The `4` comes from the fact that we have 4 color states:
          // normal, active, hover, and background
          colorIndices[index].push(
            Math.floor((cumLength / totalLength) * maxColorIdx) * 4,
          );
        }
        // The `4` comes from the fact that we have 4 color states:
        // normal, active, hover, and background
        // colorIndices[index] = rangeMap(
        //   curve.length,
        //   (i) => Math.floor((i / (curve.length - 1)) * maxColorIdx) * 4
        // );
        return colorIndices;
      }, []);
    }

    if (colorEncoding) {
      const encodingIdx = getEncodingIdx(colorEncoding);
      const encodingValueToIdx = getEncodingValueToIdx(
        getEncodingDataType(colorEncoding),
        pointConnectionColorBy === 'inherit'
          ? pointColor
          : pointConnectionColor,
      );
      return pointConnectionMap.reduce(
        (colorIndices, [index, referencePoint]) => {
          // The `4` comes from the fact that we have 4 color states:
          // normal, active, hover, and background
          colorIndices[index] =
            encodingValueToIdx(referencePoint[encodingIdx]) * 4;
          return colorIndices;
        },
        [],
      );
    }

    return new Array(pointConnectionMap.length).fill(0);
  };

  const getPointConnectionOpacities = () => {
    const opacityEncoding =
      pointConnectionOpacityBy === 'inherit'
        ? opacityBy
        : pointConnectionOpacityBy;

    if (opacityEncoding === 'segment') {
      const maxOpacityIdx = pointConnectionOpacity.length - 1;
      if (maxOpacityIdx < 1) {
        return [];
      }
      return pointConnectionMap.reduce(
        (opacities, [index, _referencePoint, length]) => {
          opacities[index] = rangeMap(
            length,
            (i) =>
              pointConnectionOpacity[
                Math.floor((i / (length - 1)) * maxOpacityIdx)
              ],
          );
          return opacities;
        },
        [],
      );
    }

    if (opacityEncoding) {
      const encodingIdx = getEncodingIdx(opacityEncoding);
      const encodingRangeMap =
        pointConnectionOpacityBy === 'inherit'
          ? opacity
          : pointConnectionOpacity;
      const encodingValueToIdx = getEncodingValueToIdx(
        getEncodingDataType(opacityEncoding),
        encodingRangeMap,
      );
      return pointConnectionMap.reduce((opacities, [index, referencePoint]) => {
        opacities[index] =
          encodingRangeMap[encodingValueToIdx(referencePoint[encodingIdx])];
        return opacities;
      }, []);
    }

    return undefined;
  };

  const getPointConnectionWidths = () => {
    const sizeEncoding =
      pointConnectionSizeBy === 'inherit' ? sizeBy : pointConnectionSizeBy;

    if (sizeEncoding === 'segment') {
      const maxSizeIdx = pointConnectionSize.length - 1;
      if (maxSizeIdx < 1) {
        return [];
      }
      return pointConnectionMap.reduce(
        (widths, [index, _referencePoint, length]) => {
          widths[index] = rangeMap(
            length,
            (i) =>
              pointConnectionSize[Math.floor((i / (length - 1)) * maxSizeIdx)],
          );
          return widths;
        },
        [],
      );
    }

    if (sizeEncoding) {
      const encodingIdx = getEncodingIdx(sizeEncoding);
      const encodingRangeMap =
        pointConnectionSizeBy === 'inherit' ? pointSize : pointConnectionSize;
      const encodingValueToIdx = getEncodingValueToIdx(
        getEncodingDataType(sizeEncoding),
        encodingRangeMap,
      );
      return pointConnectionMap.reduce((widths, [index, referencePoint]) => {
        widths[index] =
          encodingRangeMap[encodingValueToIdx(referencePoint[encodingIdx])];
        return widths;
      }, []);
    }

    return undefined;
  };

  const setPointConnectionMap = (curvePoints) => {
    pointConnectionMap = [];

    let cumLinePoints = 0;
    Object.keys(curvePoints).forEach((id, index) => {
      pointConnectionMap[id] = [
        index,
        curvePoints[id].reference,
        curvePoints[id].length / 2,
        // Used for offsetting in the buffer manipulations on
        // hovering and selecting
        cumLinePoints,
      ];
      cumLinePoints += curvePoints[id].length / 2;
    });
  };

  const setPointConnections = (newPoints) =>
    new Promise((resolve) => {
      pointConnections.setPoints([]);
      if (newPoints?.length > 0) {
        computingPointConnectionCurves = true;
        createSplineCurve(newPoints, {
          maxIntPointsPerSegment: pointConnectionMaxIntPointsPerSegment,
          tolerance: pointConnectionTolerance,
        }).then((curvePoints) => {
          setPointConnectionMap(curvePoints);
          const curvePointValues = Object.values(curvePoints);
          pointConnections.setPoints(
            curvePointValues.length === 1
              ? curvePointValues[0]
              : curvePointValues,
            {
              colorIndices: getPointConnectionColorIndices(curvePointValues),
              opacities: getPointConnectionOpacities(curvePointValues),
              widths: getPointConnectionWidths(curvePointValues),
            },
          );
          computingPointConnectionCurves = false;
          resolve();
        });
      } else {
        resolve();
      }
    });

  /**
   * Reset the point filter
   * @param {import('./types').ScatterplotMethodOptions['filter']}
   */
  const unfilter = ({ preventEvent = false } = {}) => {
    isPointsFiltered = false;
    filteredPointsSet.clear();
    normalPointsIndexBuffer.subdata(createPointIndex(numPoints));

    return new Promise((resolve) => {
      const finish = () => {
        pubSub.subscribe(
          'draw',
          () => {
            if (!preventEvent) {
              pubSub.publish('unfilter');
            }
            resolve();
          },
          1,
        );
        draw = true;
      };

      // Update point connections
      if (showPointConnections || hasPointConnections(points[0])) {
        setPointConnections(getPoints()).then(() => {
          if (!preventEvent) {
            pubSub.publish('pointConnectionsDraw');
          }
          finish();
        });
      } else {
        finish();
      }
    });
  };

  /**
   * Filter down to a set of points
   * @param {number | number[]} pointIdxs
   * @param {import('./types').ScatterplotMethodOptions['filter']}
   */
  const filter = (pointIdxs, { preventEvent = false } = {}) => {
    isPointsFiltered = true;
    filteredPointsSet.clear();

    const pointIdxsArray = Array.isArray(pointIdxs) ? pointIdxs : [pointIdxs];
    const filteredPoints = [];
    const filteredPointsBuffer = [];
    const filteredSelectedPoints = [];

    for (const pointIdx of pointIdxsArray) {
      if (!Number.isFinite(pointIdx) || pointIdx < 0 || pointIdx >= numPoints) {
        // Skip invalid filtered points
        continue;
      }

      filteredPoints.push(pointIdx);
      filteredPointsSet.add(pointIdx);

      if (selectedPointsSet.has(pointIdx)) {
        filteredSelectedPoints.push(pointIdx);
      }
    }

    const sortedFilteredPoints = insertionSort([...filteredPoints]);

    for (const pointIdx of sortedFilteredPoints) {
      filteredPointsBuffer.push.apply(
        filteredPointsBuffer,
        indexToStateTexCoord(pointIdx),
      );
    }

    // Update the normal points index buffers
    normalPointsIndexBuffer.subdata(filteredPointsBuffer);

    // Update selection
    select(filteredSelectedPoints, { preventEvent });

    // Unset any potentially hovered point
    if (!filteredPointsSet.has(hoveredPoint)) {
      hover(-1, { preventEvent });
    }

    return new Promise((resolve) => {
      const finish = () => {
        pubSub.subscribe(
          'draw',
          () => {
            if (!preventEvent) {
              pubSub.publish('filter', { points: filteredPoints });
            }
            resolve();
          },
          1,
        );
        draw = true;
      };

      // Update point connections
      if (showPointConnections || hasPointConnections(points[0])) {
        setPointConnections(getPoints()).then(() => {
          if (!preventEvent) {
            pubSub.publish('pointConnectionsDraw');
          }
          // We have to re-apply the selection because the connections might
          // have changed
          select(filteredSelectedPoints, { preventEvent });
          finish();
        });
      } else {
        finish();
      }
    });
  };

  const getPointsInView = () =>
    getPointsInBBox(
      bottomLeftNdc[0],
      bottomLeftNdc[1],
      topRightNdc[0],
      topRightNdc[1],
    );

  const getNumPointsInView = () => {
    numPointsInView = getPointsInView().length;
  };

  const getNumPointsInViewDb = throttleAndDebounce(
    getNumPointsInView,
    opacityByDensityDebounceTime,
  );

  const tweenCamera = (t) => {
    const [xStart, yStart] = cameraZoomTargetStart;
    const [xEnd, yEnd] = cameraZoomTargetEnd;

    const ti = 1.0 - t;

    const targetX = xStart * ti + xEnd * t;
    const targetY = yStart * ti + yEnd * t;
    const distance = cameraZoomDistanceStart * ti + cameraZoomDistanceEnd * t;

    camera.lookAt([targetX, targetY], distance);
  };

  const isTransitioningPoints = () => hasCachedPoints();

  const isTransitioningCamera = () => hasCachedCamera();

  const tween = (duration, easing) => {
    if (!transitionStartTime) {
      transitionStartTime = performance.now();
    }

    const dt = performance.now() - transitionStartTime;
    const t = clip(easing(dt / duration), 0, 1);

    if (isTransitioningPoints()) {
      updatePoints({ t });
    }

    if (isTransitioningCamera()) {
      tweenCamera(t);
    }

    return dt < duration;
  };

  const endTransition = () => {
    isTransitioning = false;
    transitionStartTime = null;
    transitionDuration = undefined;
    transitionEasing = undefined;
    showReticle = preTransitionShowReticle;

    clearCachedPoints();
    clearCachedCamera();

    pubSub.publish('transitionEnd');
  };

  const startTransition = ({ duration = 500, easing = DEFAULT_EASING }) => {
    if (isTransitioning) {
      pubSub.publish('transitionEnd');
    }

    isTransitioning = true;
    transitionStartTime = null;
    transitionDuration = duration;
    transitionEasing = isString(easing)
      ? EASING_FNS[easing] || DEFAULT_EASING
      : easing;
    preTransitionShowReticle = showReticle;
    showReticle = false;

    pubSub.publish('transitionStart');
  };

  /**
   * @param {import('./types').Points} newPoints
   * @param {import('./types').ScatterplotMethodOptions['draw']} options
   * @returns {Promise<void>}
   */
  const publicDraw = (newPoints, options = {}) => {
    if (isDestroyed) {
      return Promise.reject(new Error(ERROR_INSTANCE_IS_DESTROYED));
    }
    if (isDrawing) {
      return Promise.reject(new Error(ERROR_IS_DRAWING));
    }
    isDrawing = true;
    return toArrayOrientedPoints(newPoints).then((newPointsArray) =>
      new Promise((resolve) => {
        if (isDestroyed) {
          // In the special case where the instance was destroyed after
          // scatterplot.draw() was called but before toArrayOrientedPoints()
          // resolved, we will _not_ reject the promise as this would be
          // confusing. Instead we will immediately resolve and return.
          resolve();
          return;
        }

        let pointsCached = false;

        if (
          !options.preventFilterReset ||
          newPointsArray?.length !== numPoints
        ) {
          isPointsFiltered = false;
          filteredPointsSet.clear();
        }

        const drawPointConnections =
          newPointsArray &&
          hasPointConnections(newPointsArray[0]) &&
          (showPointConnections || options.showPointConnectionsOnce);

        const { zDataType, wDataType } = options;

        new Promise((resolveDraw) => {
          if (newPointsArray) {
            if (options.transition) {
              if (newPointsArray.length === numPoints) {
                pointsCached = cachePoints(newPointsArray, {
                  z: zDataType,
                  w: wDataType,
                });
              } else {
                // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning
                console.warn(
                  'Cannot transition! The number of points between the previous and current draw call must be identical.',
                );
              }
            }

            setPoints(newPointsArray, {
              zDataType,
              wDataType,
              preventFilterReset: options.preventFilterReset,
              spatialIndex: options.spatialIndex,
            }).then(() => {
              if (options.hover !== undefined) {
                hover(options.hover, { preventEvent: true });
              }

              if (options.select !== undefined) {
                select(options.select, { preventEvent: true });
              }

              if (options.filter !== undefined) {
                filter(options.filter, { preventEvent: true });
              }

              if (drawPointConnections) {
                setPointConnections(newPointsArray)
                  .then(() => {
                    pubSub.publish('pointConnectionsDraw');
                    draw = true;
                    drawReticleOnce = options.showReticleOnce;
                  })
                  .then(() => resolve());
              } else {
                resolveDraw();
              }
            });
          } else {
            resolveDraw();
          }
        }).then(() => {
          if (options.transition && pointsCached) {
            if (drawPointConnections) {
              Promise.all([
                new Promise((resolveTransition) => {
                  pubSub.subscribe(
                    'transitionEnd',
                    () => {
                      // Point connects cannot be transitioned yet so we hide them during
                      // the transition. Hence, we need to make sure we call `draw()` once
                      // the transition has ended.
                      draw = true;
                      drawReticleOnce = options.showReticleOnce;
                      resolveTransition();
                    },
                    1,
                  );
                }),
                new Promise((resolveDraw) => {
                  pubSub.subscribe('pointConnectionsDraw', resolveDraw, 1);
                }),
              ]).then(() => resolve());
            } else {
              pubSub.subscribe(
                'transitionEnd',
                () => {
                  // Point connects cannot be transitioned yet so we hide them during
                  // the transition. Hence, we need to make sure we call `draw()` once
                  // the transition has ended.
                  draw = true;
                  drawReticleOnce = options.showReticleOnce;
                  resolve();
                },
                1,
              );
            }
            startTransition({
              duration: options.transitionDuration,
              easing: options.transitionEasing,
            });
          } else {
            if (drawPointConnections) {
              Promise.all([
                new Promise((resolveDraw) => {
                  pubSub.subscribe('draw', resolveDraw, 1);
                }),
                new Promise((resolveDraw) => {
                  pubSub.subscribe('pointConnectionsDraw', resolveDraw, 1);
                }),
              ]).then(() => resolve());
            } else {
              pubSub.subscribe('draw', () => resolve(), 1);
            }
            draw = true;
            drawReticleOnce = options.showReticleOnce;
          }
        });
      }).finally(() => {
        isDrawing = false;
      }),
    );
  };

  /**
   * Draw line-based annotations.
   * @param {import('./types').Annotation[]} newAnnotations
   * @returns {Promise<void>}
   */
  const drawAnnotations = (newAnnotations) => {
    if (isDestroyed) {
      return Promise.reject(new ERROR_INSTANCE_IS_DESTROYED());
    }

    isAnnotationsDrawn = false;

    if (newAnnotations.length === 0) {
      return new Promise((resolve) => {
        annotations.clear();
        pubSub.subscribe('draw', resolve, 1);
        isAnnotationsDrawn = true;
        draw = true;
      });
    }

    return new Promise((resolve) => {
      const newPoints = [];
      const newColors = new Map();
      const newColorIndices = [];
      const newWidths = [];

      let maxNewColorIdx = -1;

      const addColorAndWidth = (annotation) => {
        newWidths.push(annotation.lineWidth || annotationLineWidth);

        const color = toRgba(annotation.lineColor || annotationLineColor, true);
        const colorId = `[${color.join(',')}]`;
        if (newColors.has(colorId)) {
          const { idx } = newColors.get(colorId);
          newColorIndices.push(idx);
        } else {
          const idx = ++maxNewColorIdx;
          newColors.set(colorId, { idx, color });
          newColorIndices.push(idx);
        }
      };

      for (const annotation of newAnnotations) {
        if (isHorizontalLine(annotation)) {
          newPoints.push([
            annotation.x1 ?? -annotationHVLineLimit,
            annotation.y,
            annotation.x2 ?? annotationHVLineLimit,
            annotation.y,
          ]);
          addColorAndWidth(annotation);
          continue;
        }

        if (isVerticalLine(annotation)) {
          newPoints.push([
            annotation.x,
            annotation.y1 ?? -annotationHVLineLimit,
            annotation.x,
            annotation.y2 ?? annotationHVLineLimit,
          ]);
          addColorAndWidth(annotation);
          continue;
        }

        if (isRect(annotation)) {
          newPoints.push([
            annotation.x1,
            annotation.y1,
            annotation.x2,
            annotation.y1,
            annotation.x2,
            annotation.y2,
            annotation.x1,
            annotation.y2,
            annotation.x1,
            annotation.y1,
          ]);
          addColorAndWidth(annotation);
          continue;
        }

        if (isDomRect(annotation)) {
          newPoints.push([
            annotation.x,
            annotation.y,
            annotation.x + annotation.width,
            annotation.y,
            annotation.x + annotation.width,
            annotation.y + annotation.height,
            annotation.x,
            annotation.y + annotation.height,
            annotation.x,
            annotation.y,
          ]);
          addColorAndWidth(annotation);
          continue;
        }

        if (isPolygon(annotation)) {
          newPoints.push(annotation.vertices.flatMap(identity));
          addColorAndWidth(annotation);
        }
      }

      annotations.setStyle({
        color: Array.from(newColors.values())
          .sort((a, b) => (a.idx > b.idx ? 1 : -1))
          .map(({ color }) => color),
      });
      annotations.setPoints(
        newPoints.length === 1 ? newPoints.flat() : newPoints,
        {
          colorIndices: newColorIndices,
          widths: newWidths,
        },
      );

      pubSub.subscribe('draw', resolve, 1);
      isAnnotationsDrawn = true;
      draw = true;
    });
  };

  /** @type {<F extends Function>(f: F) => (...args: Parameters<F>) => Promise<ReturnType<F>>} */
  const withDraw =
    (f) =>
    (...args) => {
      const out = f(...args);
      draw = true;
      return new Promise((resolve) => {
        pubSub.subscribe('draw', () => resolve(out), 1);
      });
    };

  /**
   * Get the bounding box of a set of points.
   * @param {number[]} pointIdxs - A list of point indices
   * @returns {import('./types').Rect} The bounding box
   */
  // biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox
  const getBBoxOfPoints = (pointIdxs) => {
    let xMin = Number.POSITIVE_INFINITY;
    let xMax = Number.NEGATIVE_INFINITY;
    let yMin = Number.POSITIVE_INFINITY;
    let yMax = Number.NEGATIVE_INFINITY;

    for (const pointIdx of pointIdxs) {
      const [x, y] = points[pointIdx];
      xMin = Math.min(xMin, x);
      xMax = Math.max(xMax, x);
      yMin = Math.min(yMin, y);
      yMax = Math.max(yMax, y);
    }

    return { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin };
  };

  /**
   * Zoom to an area specified as a rectangle
   * @param {import('./types').Rect} rect - The rectangle to zoom to in normalized device coordinates
   * @param {import('./types').ScatterplotMethodOptions['zoomToArea']} options
   * @returns {Promise<void>}
   */
  const zoomToArea = (rect, options = {}) =>
    new Promise((resolve) => {
      const target = vec4
        .transformMat4(
          [],
          [rect.x + rect.width / 2, rect.y + rect.height / 2, 0, 0],
          model,
        )
        .slice(0, 2);

      // Vertical field of view
      // The Arc Tangent is based on the original camera position. Otherwise
      // we would have to do `Math.atan(1 / camera.view[5])`
      // biome-ignore lint/style/useNamingConvention: FOV stands for field of view
      const vFOV = 2 * Math.atan(1);

      const aspectRatio = viewAspectRatio / dataAspectRatio;

      const distance =
        rect.height * aspectRatio >= rect.width
          ? // Distance is based on the height of the bounding box
            rect.height / 2 / Math.tan(vFOV / 2)
          : // Distance is based on the width of the bounding box
            rect.width / 2 / Math.tan(vFOV / 2) / aspectRatio;

      if (options.transition) {
        camera.config({ isFixed: true });
        cacheCamera(target, distance);
        pubSub.subscribe(
          'transitionEnd',
          () => {
            resolve();
            camera.config({ isFixed: cameraIsFixed });
          },
          1,
        );
        startTransition({
          duration: options.transitionDuration,
          easing: options.transitionEasing,
        });
      } else {
        camera.lookAt(target, distance);
        pubSub.subscribe('draw', resolve, 1);
        draw = true;
      }
    });

  /**
   * Zoom to a set of points
   * @param {number[]} pointIdxs - A list of point indices
   * @param {import('./types').ScatterplotMethodOptions['zoomToPoints']} options
   * @returns {Promise<void>}
   */
  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;

    const pointSizeNdc = getPointSizeNdc();
    const scale = 1 + (options.padding || 0);

    const w = Math.max(rect.width, pointSizeNdc) * scale;
    const h = Math.max(rect.height, pointSizeNdc) * scale;
    const x = cX - w / 2;
    const y = cY - h / 2;

    return zoomToArea({ x, y, width: w, height: h }, options);
  };

  /**
   * Zoom to a location specified in normalized devide coordinates.
   * @param {number[]} target - The camera target given in normalized device coordinates
   * @param {number} distance - The camera distance
   * @param {import('./types').ScatterplotMethodOptions['zoomToLocation']} options
   * @returns {Promise<void>}
   */
  const zoomToLocation = (target, distance, options = {}) =>
    new Promise((resolve) => {
      if (options.transition) {
        camera.config({ isFixed: true });
        cacheCamera(target, distance);
        pubSub.subscribe(
          'transitionEnd',
          () => {
            resolve();
            camera.config({ isFixed: cameraIsFixed });
          },
          1,
        );
        startTransition({
          duration: options.transitionDuration,
          easing: options.transitionEasing,
        });
      } else {
        camera.lookAt(target, distance);
        pubSub.subscribe('draw', resolve, 1);
        draw = true;
      }
    });

  /**
   * Zoom to the origin
   * @param {import('./types').ScatterplotMethodOptions['zoomToLocation']} options
   * @returns {Promise<void>}
   */
  const zoomToOrigin = (options = {}) => zoomToLocation([0, 0], 1, options);

  /**
   * Get the screen position of a point
   * @param {number} pointIdx - Point index
   * @returns {[number, number] | undefined}
   */
  const getScreenPosition = (pointIdx) => {
    if (!isPointsDrawn) {
      throw new Error(ERROR_POINTS_NOT_DRAWN);
    }

    const point = points[pointIdx];

    if (!point) {
      return undefined;
    }

    // Homogeneous coordinates of the point
    const v = [point[0], point[1], 0, 1];

    // Convert to clip space
    mat4.multiply(
      scratch,
      projectionLocal,
      mat4.multiply(scratch, camera.view, model),
    );

    vec4.transformMat4(v, v, scratch);

    // Finally, we convert to the screen space
    const x = (currentWidth * (v[0] + 1)) / 2;
    const y = currentHeight * (0.5 - v[1] / 2);

    return [x, y];
  };

  const updatePointConnectionStyle = () => {
    pointConnections.setStyle({
      color: getColors(
        pointConnectionColor,
        pointConnectionColorActive,
        pointConnectionColorHover,
      ),
      opacity:
        pointConnectionOpacity === null ? null : pointConnectionOpacity[0],
      width: pointConnectionSize[0],
    });
  };

  const updateLassoInitiatorStyle = () => {
    const v = Math.round(backgroundColorBrightness) > 0.5 ? 0 : 255;
    lassoManager.initiator.style.border = `1px dashed rgba(${v}, ${v}, ${v}, 0.33)`;
    lassoManager.initiator.style.background = `rgba(${v}, ${v}, ${v}, 0.1)`;
  };

  const updateLassoLongPressIndicatorStyle = () => {
    const v = Math.round(backgroundColorBrightness) > 0.5 ? 0 : 255;

    lassoManager.longPressIndicator.style.color = `rgb(${v}, ${v}, ${v})`;
    lassoManager.longPressIndicator.dataset.color = `rgb(${v}, ${v}, ${v})`;

    const rgb = lassoColor.map((c) => Math.round(c * 255));
    lassoManager.longPressIndicator.dataset.activeColor = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
  };

  const setBackgroundColor = (newBackgroundColor) => {
    if (!newBackgroundColor) {
      return;
    }

    backgroundColor = toRgba(newBackgroundColor, true);
    backgroundColorBrightness = rgbBrightness(backgroundColor);
    updateLassoInitiatorStyle();
    updateLassoLongPressIndicatorStyle();
  };

  const setBackgroundImage = (newBackgroundImage) => {
    if (!newBackgroundImage) {
      backgroundImage = null;
    } else if (isString(newBackgroundImage)) {
      createTextureFromUrl(renderer.regl, newBackgroundImage)
        .then((texture) => {
          backgroundImage = texture;
          draw = true;
          pubSub.publish('backgroundImageReady');
        })
        .catch(() => {
          // biome-ignore lint/suspicious/noConsole: This is a legitimately useful error log
          console.error(`Count not create texture from ${newBackgroundImage}`);
          backgroundImage = null;
        });
    } else if (newBackgroundImage._reglType === 'texture2d') {
      backgroundImage = newBackgroundImage;
    } else {
      backgroundImage = null;
    }
  };

  const setCameraDistance = (distance) => {
    if (distance > 0) {
      camera.lookAt(camera.target, distance, camera.rotation);
    }
  };

  const setCameraRotation = (rotation) => {
    if (rotation !== null) {
      camera.lookAt(camera.target, camera.distance[0], rotation);
    }
  };

  const setCameraTarget = (target) => {
    if (target) {
      camera.lookAt(target, camera.distance[0], camera.rotation);
    }
  };

  const setCameraView = (view) => {
    if (view) {
      camera.setView(view);
    }
  };

  const setCameraIsFixed = (isFixed) => {
    cameraIsFixed = Boolean(isFixed);
    camera.config({ isFixed: cameraIsFixed });
  };

  const setLassoColor = (newLassoColor) => {
    if (!newLassoColor) {
      return;
    }

    lassoColor = toRgba(newLassoColor, true);

    lasso.setStyle({ color: lassoColor });

    const rgb = lassoColor.map((c) => Math.round(c * 255));
    lassoManager.longPressIndicator.dataset.activeColor = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
  };

  const setLassoLineWidth = (newLassoLineWidth) => {
    if (Number.isNaN(+newLassoLineWidth) || +newLassoLineWidth < 1) {
      return;
    }

    lassoLineWidth = +newLassoLineWidth;

    lasso.setStyle({ width: lassoLineWidth });
  };

  const setLassoMinDelay = (newLassoMinDelay) => {
    if (!+newLassoMinDelay) {
      return;
    }

    lassoMinDelay = +newLassoMinDelay;

    lassoManager.set({
      minDelay: lassoMinDelay,
    });
  };

  const setLassoMinDist = (newLassoMinDist) => {
    if (!+newLassoMinDist) {
      return;
    }

    lassoMinDist = +newLassoMinDist;

    lassoManager.set({
      minDist: lassoMinDist,
    });
  };

  const setLassoClearEvent = (newLassoClearEvent) => {
    lassoClearEvent = limit(
      LASSO_CLEAR_EVENTS,
      lassoClearEvent,
    )(newLassoClearEvent);
  };

  const setLassoInitiator = (newLassoInitiator) => {
    lassoInitiator = Boolean(newLassoInitiator);

    lassoManager.set({
      enableInitiator: lassoInitiator,
    });
  };

  const setLassoInitiatorParentElement = (newLassoInitiatorParentElement) => {
    lassoInitiatorParentElement = newLassoInitiatorParentElement;

    lassoManager.set({
      initiatorParentElement: lassoInitiatorParentElement,
    });
  };

  const setLassoLongPressIndicatorParentElement = (newParentElement) => {
    lassoLongPressIndicatorParentElement = newParentElement;

    lassoManager.set({
      longPressIndicatorParentElement: lassoLongPressIndicatorParentElement,
    });
  };

  const setLassoOnLongPress = (newLassoOnLongPress) => {
    lassoOnLongPress = Boolean(newLassoOnLongPress);
  };

  const setLassoLongPressTime = (newLassoOnLongPressTime) => {
    lassoLongPressTime = Number(newLassoOnLongPressTime);
  };

  const setLassoLongPressAfterEffectTime = (newTime) => {
    lassoLongPressAfterEffectTime = Number(newTime);
  };

  const setLassoLongPressEffectDelay = (newDelay) => {
    lassoLongPressEffectDelay = Number(newDelay);
  };

  const setLassoLongPressRevertEffectTime = (newTime) => {
    lassoLongPressRevertEffectTime = Number(newTime);
  };

  const setLassoType = (newType) => {
    if (newType === 'brush') {
      lassoManager.set({
        type: newType,
        minDist: Math.max(LASSO_BRUSH_MIN_MIN_DIST, lassoMinDist),
      });
    } else {
      lassoManager.set({
        type: newType,
        minDist: lassoMinDist,
      });
    }
    lassoType = lassoManager.get('type');
  };

  const setLassoBrushSize = (newBrushSize) => {
    lassoBrushSize = Number(newBrushSize) || lassoBrushSize;
    lassoManager.set({ brushSize: lassoBrushSize });
  };

  const updateActionKeyMapChange = () => {
    if (actionKeyMap[KEY_ACTION_ROTATE]) {
      camera.config({
        isRotate: true,
        mouseDownMoveModKey: actionKeyMap[KEY_ACTION_ROTATE],
      });
    } else {
      camera.config({
        isRotate: false,
      });
    }
  };

  const setActionKeyMap = (newActionKeyMap) => {
    actionKeyMap = Object.entries(newActionKeyMap).reduce(
      (map, [action, key]) => {
        if (KEYS.includes(key) && KEY_ACTIONS.includes(action)) {
          map[action] = key;
        }
        return map;
      },
      {},
    );

    updateActionKeyMapChange();
  };

  const setMouseMode = (newMouseMode) => {
    mouseMode = limit(MOUSE_MODES, MOUSE_MODE_PANZOOM)(newMouseMode);

    camera.config({
      defaultMouseDownMoveAction:
        mouseMode === MOUSE_MODE_ROTATE ? 'rotate' : 'pan',
    });
  };

  const setShowReticle = (newShowReticle) => {
    if (newShowReticle === null) {
      return;
    }

    showReticle = newShowReticle;
  };

  const setReticleColor = (newReticleColor) => {
    if (!newReticleColor) {
      return;
    }

    reticleColor = toRgba(newReticleColor, true);

    reticleHLine.setStyle({ color: reticleColor });
    reticleVLine.setStyle({ color: reticleColor });
  };

  // biome-ignore lint/style/useNamingConvention: XScale are two words
  const setXScale = (newXScale) => {
    if (!newXScale) {
      return;
    }

    xScale = newXScale;
    xDomainStart = newXScale.domain()[0];
    xDomainSize = newXScale ? newXScale.domain()[1] - newXScale.domain()[0] : 0;
    xScale.range([0, currentWidth]);
    updateScales();
  };

  // biome-ignore lint/style/useNamingConvention: YScale are two words
  const setYScale = (newYScale) => {
    if (!newYScale) {
      return;
    }

    yScale = newYScale;
    yDomainStart = yScale.domain()[0];
    yDomainSize = yScale ? yScale.domain()[1] - yScale.domain()[0] : 0;
    yScale.range([currentHeight, 0]);
    updateScales();
  };

  const setDeselectOnDblClick = (newDeselectOnDblClick) => {
    deselectOnDblClick = !!newDeselectOnDblClick;
  };

  const setDeselectOnEscape = (newDeselectOnEscape) => {
    deselectOnEscape = !!newDeselectOnEscape;
  };

  const setShowPointConnections = (newShowPointConnections) => {
    showPointConnections = !!newShowPointConnections;
    if (showPointConnections) {
      if (isPointsDrawn && hasPointConnections(points[0])) {
        setPointConnections(getPoints()).then(() => {
          pubSub.publish('pointConnectionsDraw');
          draw = true;
        });
      }
    } else {
      setPointConnections();
    }
  };

  const setPointConnectionColors = (setter, getInheritance) => (newColors) => {
    if (newColors === 'inherit') {
      setter([...getInheritance()]);
    } else {
      const tmpColors = isMultipleColors(newColors) ? newColors : [newColors];
      setter(tmpColors.map((color) => toRgba(color, true)));
    }
    updatePointConnectionStyle();
  };

  const setPointConnectionColor = setPointConnectionColors(
    (newColors) => {
      pointConnectionColor = newColors;
    },
    () => pointColor,
  );

  const setPointConnectionColorActive = setPointConnectionColors(
    (newColors) => {
      pointConnectionColorActive = newColors;
    },
    () => pointColorActive,
  );

  const setPointConnectionColorHover = setPointConnectionColors(
    (newColors) => {
      pointConnectionColorHover = newColors;
    },
    () => pointColorHover,
  );

  const setPointConnectionOpacity = (newOpacity) => {
    if (isConditionalArray(newOpacity, isPositiveNumber, { minLength: 1 })) {
      pointConnectionOpacity = [...newOpacity];
    }

    if (isStrictlyPositiveNumber(+newOpacity)) {
      pointConnectionOpacity = [+newOpacity];
    }

    pointConnectionColor = pointConnectionColor.map((color) => {
      color[3] = Number.isNaN(+pointConnectionOpacity[0])
        ? color[3]
        : +pointConnectionOpacity[0];
      return color;
    });

    updatePointConnectionStyle();
  };

  const setPointConnectionOpacityActive = (newOpacity) => {
    if (!Number.isNaN(+newOpacity) && +newOpacity) {
      pointConnectionOpacityActive = +newOpacity;
    }
  };

  const setPointConnectionSize = (newPointConnectionSize) => {
    if (
      isConditionalArray(newPointConnectionSize, isPositiveNumber, {
        minLength: 1,
      })
    ) {
      pointConnectionSize = [...newPointConnectionSize];
    }

    if (isStrictlyPositiveNumber(+newPointConnectionSize)) {
      pointConnectionSize = [+newPointConnectionSize];
    }

    updatePointConnectionStyle();
  };

  const setPointConnectionSizeActive = (newPointConnectionSizeActive) => {
    if (
      !Number.isNaN(+newPointConnectionSizeActive) &&
      +newPointConnectionSizeActive
    ) {
      pointConnectionSizeActive = Math.max(0, newPointConnectionSizeActive);
    }
  };

  const setPointConnectionMaxIntPointsPerSegment = (
    newPointConnectionMaxIntPointsPerSegment,
  ) => {
    pointConnectionMaxIntPointsPerSegment = Math.max(
      0,
      newPointConnectionMaxIntPointsPerSegment,
    );
  };

  const setPointConnectionTolerance = (newPointConnectionTolerance) => {
    pointConnectionTolerance = Math.max(0, newPointConnectionTolerance);
  };

  const setPointSizeMouseDetection = (newPointSizeMouseDetection) => {
    pointSizeMouseDetection = newPointSizeMouseDetection;
    computePointSizeMouseDetection();
  };

  const setPointScaleMode = (newPointScaleMode) => {
    switch (newPointScaleMode) {
      case 'linear': {
        pointScaleMode = newPointScaleMode;
        getPointScale = getLinearPointScale;
        break;
      }
      case 'constant': {
        pointScaleMode = newPointScaleMode;
        getPointScale = getConstantPointScale;
        break;
      }
      default: {
        pointScaleMode = 'asinh';
        getPointScale = getAsinhPointScale;
        break;
      }
    }
  };

  const setOpacityByDensityFill = (newOpacityByDensityFill) => {
    opacityByDensityFill = +newOpacityByDensityFill;
  };

  const setOpacityInactiveMax = (newOpacityInactiveMax) => {
    opacityInactiveMax = +newOpacityInactiveMax;
  };

  const setOpacityInactiveScale = (newOpacityInactiveScale) => {
    opacityInactiveScale = +newOpacityInactiveScale;
  };

  const setAnnotationLineColor = (newAnnotationLineColor) => {
    annotationLineColor = toRgba(newAnnotationLineColor);
  };

  const setAnnotationLineWidth = (newAnnotationLineWidth) => {
    annotationLineWidth = +newAnnotationLineWidth;
  };

  // biome-ignore lint/style/useNamingConvention: HVLine stands for horizontal vertical line
  const setAnnotationHVLineLimit = (newAnnotationHVLineLimit) => {
    annotationHVLineLimit = +newAnnotationHVLineLimit;
  };

  const setGamma = (newGamma) => {
    renderer.gamma = newGamma;
  };

  const setAntiAliasing = (newAntiAliasing) => {
    antiAliasing = Number(newAntiAliasing) || 0.5;
  };

  const setPixelAligned = (newPixelAligned) => {
    pixelAligned = Boolean(newPixelAligned);
  };

  /** @type {<Key extends keyof import('./types').Properties>(property: Key) => import('./types').Properties[Key] } */
  const get = (prop) => {
    const [property] = Object.keys(
      checkDeprecations({
        [prop]: SKIP_DEPRECATION_VALUE_TRANSLATION,
      }),
    );

    if (property === 'aspectRatio') {
      return dataAspectRatio;
    }

    if (property === 'background') {
      return backgroundColor;
    }

    if (property === 'backgroundColor') {
      return backgroundColor;
    }

    if (property === 'backgroundImage') {
      return backgroundImage;
    }

    if (property === 'camera') {
      return camera;
    }

    if (property === 'cameraTarget') {
      return camera.target;
    }

    if (property === 'cameraDistance') {
      return camera.distance[0];
    }

    if (property === 'cameraRotation') {
      return camera.rotation;
    }

    if (property === 'cameraView') {
      return camera.view;
    }

    if (property === 'cameraIsFixed') {
      return cameraIsFixed;
    }

    if (property === 'canvas') {
      return canvas;
    }

    if (property === 'colorBy') {
      return colorBy;
    }

    if (property === 'sizeBy') {
      return sizeBy;
    }

    if (property === 'deselectOnDblClick') {
      return deselectOnDblClick;
    }

    if (property === 'deselectOnEscape') {
      return deselectOnEscape;
    }

    if (property === 'height') {
      return height;
    }

    if (property === 'lassoColor') {
      return lassoColor;
    }

    if (property === 'lassoLineWidth') {
      return lassoLineWidth;
    }

    if (property === 'lassoMinDelay') {
      return lassoMinDelay;
    }

    if (property === 'lassoMinDist') {
      return lassoMinDist;
    }

    if (property === 'lassoClearEvent') {
      return lassoClearEvent;
    }

    if (property === 'lassoInitiator') {
      return lassoInitiator;
    }

    if (property === 'lassoInitiatorElement') {
      return lassoManager.initiator;
    }

    if (property === 'lassoInitiatorParentElement') {
      return lassoInitiatorParentElement;
    }

    if (property === 'lassoLongPressIndicatorParentElement') {
      return lassoLongPressIndicatorParentElement;
    }

    if (property === 'lassoOnLongPress') {
      return lassoOnLongPress;
    }

    if (property === 'lassoType') {
      return lassoType;
    }

    if (property === 'lassoBrushSize') {
      return lassoBrushSize;
    }

    if (property === 'mouseMode') {
      return mouseMode;
    }

    if (property === 'opacity') {
      return opacity.length === 1 ? opacity[0] : opacity;
    }

    if (property === 'opacityBy') {
      return opacityBy;
    }

    if (property === 'opacityByDensityFill') {
      return opacityByDensityFill;
    }

    if (property === 'opacityByDensityDebounceTime') {
      return opacityByDensityDebounceTime;
    }

    if (property === 'opacityInactiveMax') {
      return opacityInactiveMax;
    }

    if (property === 'opacityInactiveScale') {
      return opacityInactiveScale;
    }

    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: points.length }, (_, i) => i);
    }

    if (property === 'pointsInView') {
      return getPointsInView();
    }

    if (property === 'pointColor') {
      return pointColor.length === 1 ? pointColor[0] : pointColor;
    }

    if (property === 'pointColorActive') {
      return pointColorActive.length === 1
        ? pointColorActive[0]
        : pointColorActive;
    }

    if (property === 'pointColorHover') {
      return pointColorHover.length === 1
        ? pointColorHover[0]
        : pointColorHover;
    }

    if (property === 'pointOutlineWidth') {
      return pointOutlineWidth;
    }

    if (property === 'pointSize') {
      return pointSize.length === 1 ? pointSize[0] : pointSize;
    }

    if (property === 'pointSizeSelected') {
      return pointSizeSelected;
    }

    if (property === 'pointSizeMouseDetection') {
      return pointSizeMouseDetection;
    }

    if (property === 'showPointConnections') {
      return showPointConnections;
    }

    if (property === 'pointConnectionColor') {
      return pointConnectionColor.length === 1
        ? pointConnectionColor[0]
        : pointConnectionColor;
    }

    if (property === 'pointConnectionColorActive') {
      return pointConnectionColorActive.length === 1
        ? pointConnectionColorActive[0]
        : pointConnectionColorActive;
    }

    if (property === 'pointConnectionColorHover') {
      return pointConnectionColorHover.length === 1
        ? pointConnectionColorHover[0]
        : pointConnectionColorHover;
    }

    if (property === 'pointConnectionColorBy') {
      return pointConnectionColorBy;
    }

    if (property === 'pointConnectionOpacity') {
      return pointConnectionOpacity.length === 1
        ? pointConnectionOpacity[0]
        : pointConnectionOpacity;
    }

    if (property === 'pointConnectionOpacityBy') {
      return pointConnectionOpacityBy;
    }

    if (property === 'pointConnectionOpacityActive') {
      return pointConnectionOpacityActive;
    }

    if (property === 'pointConnectionSize') {
      return pointConnectionSize.length === 1
        ? pointConnectionSize[0]
        : pointConnectionSize;
    }

    if (property === 'pointConnectionSizeActive') {
      return pointConnectionSizeActive;
    }

    if (property === 'pointConnectionSizeBy') {
      return pointConnectionSizeBy;
    }

    if (property === 'pointConnectionMaxIntPointsPerSegment') {
      return pointConnectionMaxIntPointsPerSegment;
    }

    if (property === 'pointConnectionTolerance') {
      return pointConnectionTolerance;
    }

    if (property === 'pointScaleMode') {
      return pointScaleMode;
    }

    if (property === 'reticleColor') {
      return reticleColor;
    }

    if (property === 'regl') {
      return renderer.regl;
    }

    if (property === 'showReticle') {
      return showReticle;
    }

    if (property === 'version') {
      return version;
    }

    if (property === 'width') {
      return width;
    }

    if (property === 'xScale') {
      return xScale;
    }

    if (property === 'yScale') {
      return yScale;
    }

    if (property === 'performanceMode') {
      return performanceMode;
    }

    if (property === 'renderPointsAsSquares') {
      return renderPointsAsSquares;
    }

    if (property === 'disableAlphaBlending') {
      return disableAlphaBlending;
    }

    if (property === 'gamma') {
      return renderer.gamma;
    }

    if (property === 'renderer') {
      return renderer;
    }

    if (property === 'isDestroyed') {
      return isDestroyed;
    }

    if (property === 'isDrawing') {
      return isDrawing;
    }

    if (property === 'isPointsDrawn') {
      return isPointsDrawn;
    }

    if (property === 'isPointsFiltered') {
      return isPointsFiltered;
    }

    if (property === 'isAnnotationsDrawn') {
      return isAnnotationsDrawn;
    }

    if (property === 'zDataType') {
      return valueZDataType;
    }

    if (property === 'wDataType') {
      return valueWDataType;
    }

    if (property === 'spatialIndex') {
      return spatialIndex?.data;
    }

    if (property === 'annotationLineColor') {
      return annotationLineColor;
    }

    if (property === 'annotationLineWidth') {
      return annotationLineWidth;
    }

    if (property === 'annotationHVLineLimit') {
      return annotationHVLineLimit;
    }

    if (property === 'antiAliasing') {
      return antiAliasing;
    }

    if (property === 'pixelAligned') {
      return pixelAligned;
    }

    if (property === 'actionKeyMap') {
      return { ...actionKeyMap };
    }

    return undefined;
  };

  /** @type {(properties: Partial<import('./types').Settable>) => Promise<void>} */
  const set = (properties = {}) => {
    if (isDestroyed) {
      return Promise.reject(new Error(ERROR_INSTANCE_IS_DESTROYED));
    }

    checkDeprecations(properties);

    if (
      properties.backgroundColor !== undefined ||
      properties.background !== undefined
    ) {
      setBackgroundColor(properties.backgroundColor || properties.background);
    }

    if (properties.backgroundImage !== undefined) {
      setBackgroundImage(properties.backgroundImage);
    }

    if (properties.cameraTarget !== undefined) {
      setCameraTarget(properties.cameraTarget);
    }

    if (properties.cameraDistance !== undefined) {
      setCameraDistance(properties.cameraDistance);
    }

    if (properties.cameraRotation !== undefined) {
      setCameraRotation(properties.cameraRotation);
    }

    if (properties.cameraView !== undefined) {
      setCameraView(properties.cameraView);
    }

    if (properties.cameraIsFixed !== undefined) {
      setCameraIsFixed(properties.cameraIsFixed);
    }

    if (properties.colorBy !== undefined) {
      setColorBy(properties.colorBy);
    }

    if (properties.pointColor !== undefined) {
      setPointColor(properties.pointColor);
    }

    if (properties.pointColorActive !== undefined) {
      setPointColorActive(properties.pointColorActive);
    }

    if (properties.pointColorHover !== undefined) {
      setPointColorHover(properties.pointColorHover);
    }

    if (properties.pointSize !== undefined) {
      setPointSize(properties.pointSize);
    }

    if (properties.pointSizeSelected !== undefined) {
      setPointSizeSelected(properties.pointSizeSelected);
    }

    if (properties.pointSizeMouseDetection !== undefined) {
      setPointSizeMouseDetection(properties.pointSizeMouseDetection);
    }

    if (properties.sizeBy !== undefined) {
      setSizeBy(properties.sizeBy);
    }

    if (properties.opacity !== undefined) {
      setOpacity(properties.opacity);
    }

    if (properties.showPointConnections !== undefined) {
      setShowPointConnections(properties.showPointConnections);
    }

    if (properties.pointConnectionColor !== undefined) {
      setPointConnectionColor(properties.pointConnectionColor);
    }

    if (properties.pointConnectionColorActive !== undefined) {
      setPointConnectionColorActive(properties.pointConnectionColorActive);
    }

    if (properties.pointConnectionColorHover !== undefined) {
      setPointConnectionColorHover(properties.pointConnectionColorHover);
    }

    if (properties.pointConnectionColorBy !== undefined) {
      setPointConnectionColorBy(properties.pointConnectionColorBy);
    }

    if (properties.pointConnectionOpacityBy !== undefined) {
      setPointConnectionOpacityBy(properties.pointConnectionOpacityBy);
    }

    if (properties.pointConnectionOpacity !== undefined) {
      setPointConnectionOpacity(properties.pointConnectionOpacity);
    }

    if (properties.pointConnectionOpacityActive !== undefined) {
      setPointConnectionOpacityActive(properties.pointConnectionOpacityActive);
    }

    if (properties.pointConnectionSize !== undefined) {
      setPointConnectionSize(properties.pointConnectionSize);
    }

    if (properties.pointConnectionSizeActive !== undefined) {
      setPointConnectionSizeActive(properties.pointConnectionSizeActive);
    }

    if (properties.pointConnectionSizeBy !== undefined) {
      setPointConnectionSizeBy(properties.pointConnectionSizeBy);
    }

    if (properties.pointConnectionMaxIntPointsPerSegment !== undefined) {
      setPointConnectionMaxIntPointsPerSegment(
        properties.pointConnectionMaxIntPointsPerSegment,
      );
    }

    if (properties.pointConnectionTolerance !== undefined) {
      setPointConnectionTolerance(properties.pointConnectionTolerance);
    }

    if (properties.pointScaleMode !== undefined) {
      setPointScaleMode(properties.pointScaleMode);
    }

    if (properties.opacityBy !== undefined) {
      setOpacityBy(properties.opacityBy);
    }

    if (properties.lassoColor !== undefined) {
      setLassoColor(properties.lassoColor);
    }

    if (properties.lassoLineWidth !== undefined) {
      setLassoLineWidth(properties.lassoLineWidth);
    }

    if (properties.lassoMinDelay !== undefined) {
      setLassoMinDelay(properties.lassoMinDelay);
    }

    if (properties.lassoMinDist !== undefined) {
      setLassoMinDist(properties.lassoMinDist);
    }

    if (properties.lassoClearEvent !== undefined) {
      setLassoClearEvent(properties.lassoClearEvent);
    }

    if (properties.lassoInitiator !== undefined) {
      setLassoInitiator(properties.lassoInitiator);
    }

    if (properties.lassoInitiatorParentElement !== undefined) {
      setLassoInitiatorParentElement(properties.lassoInitiatorParentElement);
    }

    if (properties.lassoLongPressIndicatorParentElement !== undefined) {
      setLassoLongPressIndicatorParentElement(
        properties.lassoLongPressIndicatorParentElement,
      );
    }

    if (properties.lassoOnLongPress !== undefined) {
      setLassoOnLongPress(properties.lassoOnLongPress);
    }

    if (properties.lassoLongPressTime !== undefined) {
      setLassoLongPressTime(properties.lassoLongPressTime);
    }

    if (properties.lassoLongPressAfterEffectTime !== undefined) {
      setLassoLongPressAfterEffectTime(
        properties.lassoLongPressAfterEffectTime,
      );
    }

    if (properties.lassoLongPressEffectDelay !== undefined) {
      setLassoLongPressEffectDelay(properties.lassoLongPressEffectDelay);
    }

    if (properties.lassoLongPressRevertEffectTime !== undefined) {
      setLassoLongPressRevertEffectTime(
        properties.lassoLongPressRevertEffectTime,
      );
    }

    if (properties.lassoType !== undefined) {
      setLassoType(properties.lassoType);
    }

    if (properties.lassoBrushSize !== undefined) {
      setLassoBrushSize(properties.lassoBrushSize);
    }

    if (properties.actionKeyMap !== undefined) {
      setActionKeyMap(properties.actionKeyMap);
    }

    if (properties.mouseMode !== undefined) {
      setMouseMode(properties.mouseMode);
    }

    if (properties.showReticle !== undefined) {
      setShowReticle(properties.showReticle);
    }

    if (properties.reticleColor !== undefined) {
      setReticleColor(properties.reticleColor);
    }

    if (properties.pointOutlineWidth !== undefined) {
      setPointOutlineWidth(properties.pointOutlineWidth);
    }

    if (properties.height !== undefined) {
      setHeight(properties.height);
    }

    if (properties.width !== undefined) {
      setWidth(properties.width);
    }

    if (properties.aspectRatio !== undefined) {
      setDataAspectRatio(properties.aspectRatio);
    }

    if (properties.xScale !== undefined) {
      setXScale(properties.xScale);
    }

    if (properties.yScale !== undefined) {
      setYScale(properties.yScale);
    }

    if (properties.deselectOnDblClick !== undefined) {
      setDeselectOnDblClick(properties.deselectOnDblClick);
    }

    if (properties.deselectOnEscape !== undefined) {
      setDeselectOnEscape(properties.deselectOnEscape);
    }

    if (properties.opacityByDensityFill !== undefined) {
      setOpacityByDensityFill(properties.opacityByDensityFill);
    }

    if (properties.opacityInactiveMax !== undefined) {
      setOpacityInactiveMax(properties.opacityInactiveMax);
    }

    if (properties.opacityInactiveScale !== undefined) {
      setOpacityInactiveScale(properties.opacityInactiveScale);
    }

    if (properties.gamma !== undefined) {
      setGamma(properties.gamma);
    }

    if (properties.annotationLineColor !== undefined) {
      setAnnotationLineColor(properties.annotationLineColor);
    }

    if (properties.annotationLineWidth !== undefined) {
      setAnnotationLineWidth(properties.annotationLineWidth);
    }

    if (properties.annotationHVLineLimit !== undefined) {
      setAnnotationHVLineLimit(properties.annotationHVLineLimit);
    }

    if (properties.antiAliasing !== undefined) {
      setAntiAliasing(properties.antiAliasing);
    }

    if (properties.pixelAligned !== undefined) {
      setPixelAligned(properties.pixelAligned);
    }

    // setWidth and setHeight can be async when width or height are set to
    // 'auto'. And since draw() would have anyway been async we can just make
    // all calls async.
    return new Promise((resolve) => {
      window.requestAnimationFrame(() => {
        if (isDestroyed || !canvas) {
          // Instance was destroyed in between
          return;
        }
        updateViewAspectRatio();
        camera.refresh();
        renderer.refresh();
        redraw();
        resolve();
      });
    });
  };

  /**
   * @param {number[]} cameraView
   * @param {import('./types').ScatterplotMethodOptions['preventEvent']} options
   */
  const view = (cameraView, { preventEvent = false } = {}) => {
    setCameraView(cameraView);
    draw = true;
    preventEventView = preventEvent;
  };

  const initCamera = () => {
    if (!camera) {
      camera = createDom2dCamera(canvas, {
        isFixed: cameraIsFixed,
        isPanInverted: [false, true],
        defaultMouseDownMoveAction:
          mouseMode === MOUSE_MODE_ROTATE ? 'rotate' : 'pan',
      });
    }

    if (initialProperties.cameraView) {
      camera.setView(mat4.clone(initialProperties.cameraView));
    } else if (
      initialProperties.cameraTarget ||
      initialProperties.cameraDistance ||
      initialProperties.cameraRotation
    ) {
      camera.lookAt(
        [...(initialProperties.cameraTarget || DEFAULT_TARGET)],
        initialProperties.cameraDistance || DEFAULT_DISTANCE,
        initialProperties.cameraRotation || DEFAULT_ROTATION,
      );
    } else {
      camera.setView(mat4.clone(DEFAULT_VIEW));
    }

    topRightNdc = getScatterGlPos(1, 1);
    bottomLeftNdc = getScatterGlPos(-1, -1);
  };

  /**
   * @param {import('./types').ScatterplotMethodOptions['preventEvent']} options
   */
  const reset = ({ preventEvent = false } = {}) => {
    initCamera();
    updateScales();

    if (preventEvent) {
      return;
    }

    pubSub.publish('view', {
      view: camera.view,
      camera,
      xScale,
      yScale,
    });
  };

  const keyUpHandler = ({ key }) => {
    switch (key) {
      case 'Escape': {
        if (deselectOnEscape) {
          deselect();
        }
        break;
      }
      default:
      // Nothing
    }
  };

  const mouseEnterCanvasHandler = () => {
    isMouseInCanvas = true;
    isMouseOverCanvasChecked = true;
  };

  const mouseLeaveCanvasHandler = () => {
    hover();
    isMouseInCanvas = false;
    isMouseOverCanvasChecked = true;
    draw = true;
  };

  const wheelHandler = () => {
    draw = true;
  };

  /** @type {() => void} */
  const clearPoints = () => {
    setPoints([]);
    pointConnections.clear();
  };

  /** @type {() => void} */
  const clearPointConnections = () => {
    pointConnections.clear();
  };

  /** @type {() => void} */
  const clearAnnotations = () => {
    drawAnnotations([]);
  };

  /** @type {() => void} */
  const clear = () => {
    clearPoints();
    clearAnnotations();
  };

  const resizeHandler = () => {
    camera.refresh();
    const autoWidth = width === AUTO;
    const autoHeight = height === AUTO;
    if (autoWidth || autoHeight) {
      const { width: newWidth, height: newHeight } =
        canvas.getBoundingClientRect();

      if (autoWidth) {
        setCurrentWidth(newWidth);
      }

      if (autoHeight) {
        setCurrentHeight(newHeight);
      }

      updateViewAspectRatio();
      draw = true;
    }
  };

  /**
   * Export view as `ImageData` using custom render settings
   * @param {import('./types').ScatterplotMethodOptions['export']} options
   * @returns {Promise<ImageData>}
   */
  const exportFnAdvanced = async (options) => {
    canvas.style.userSelect = 'none';

    const dpr = window.devicePixelRatio;

    const currPointSize = pointSize;
    const currWidth = width;
    const currHeight = height;
    const currRendererWidth = renderer.canvas.width / dpr;
    const currRendererHeight = renderer.canvas.height / dpr;
    const currPixelAligned = pixelAligned;
    const currAntiAliasing = antiAliasing;

    const scale = options?.scale || 1;
    const newPointSize = Array.isArray(pointSize)
      ? pointSize.map((s) => s * scale)
      : pointSize * scale;
    const newWidth = currentWidth * scale;
    const newHeight = currentHeight * scale;

    setPointSize(newPointSize);
    setWidth(newWidth);
    setHeight(newHeight);
    setPixelAligned(options?.pixelAligned || pixelAligned);
    setAntiAliasing(options?.antiAliasing || antiAliasing);

    renderer.resize(width, height);
    renderer.refresh();

    await new Promise((resolve) => {
      pubSub.subscribe('draw', resolve, 1);
      redraw();
    });

    const imageData = canvas
      .getContext('2d')
      .getImageData(0, 0, canvas.width, canvas.height);

    renderer.resize(currRendererWidth, currRendererHeight);
    renderer.refresh();

    setPointSize(currPointSize);
    setWidth(currWidth);
    setHeight(currHeight);
    setPixelAligned(currPixelAligned);
    setAntiAliasing(currAntiAliasing);

    await new Promise((resolve) => {
      pubSub.subscribe('draw', resolve, 1);
      redraw();
    });

    canvas.style.userSelect = null;

    return imageData;
  };

  /**
   * Export view as `ImageData` using the current render settings
   * @overload
   * @param {undefined} options
   * @return {ImageData}
   */
  /**
   * Export view as `ImageData` using custom render settings
   * @overload
   * @param {import('./types').ScatterplotMethodOptions['export']} options
   * @return {Promise<ImageData>}
   */
  /**
   * Export view
   * @param {import('./types').ScatterplotMethodOptions['export']} [options]
   * @returns {Promise<ImageData>}
   */
  const exportFn = (options) => {
    if (options === undefined) {
      return canvas
        .getContext('2d')
        .getImageData(0, 0, canvas.width, canvas.height);
    }

    return exportFnAdvanced(options);
  };

  const init = () => {
    updateViewAspectRatio();
    initCamera();
    updateScales();

    lasso = createLine(renderer.regl, {
      color: lassoColor,
      width: lassoLineWidth,
      is2d: true,
    });
    pointConnections = createLine(renderer.regl, {
      color: getColors(
        pointConnectionColor,
        pointConnectionColorActive,
        pointConnectionColorHover,
      ),
      opacity:
        pointConnectionOpacity === null ? null : pointConnectionOpacity[0],
      width: pointConnectionSize[0],
      widthActive: pointConnectionSizeActive,
      is2d: true,
    });
    reticleHLine = createLine(renderer.regl, {
      color: reticleColor,
      width: 1,
      is2d: true,
    });
    reticleVLine = createLine(renderer.regl, {
      color: reticleColor,
      width: 1,
      is2d: true,
    });
    annotations = createLine(renderer.regl, {
      color: annotationLineColor,
      width: annotationLineWidth,
      is2d: true,
    });
    computePointSizeMouseDetection();

    // Event listeners
    canvas.addEventListener('wheel', wheelHandler);

    // Buffers
    normalPointsIndexBuffer = renderer.regl.buffer();
    selectedPointsIndexBuffer = renderer.regl.buffer();
    hoveredPointIndexBuffer = renderer.regl.buffer({
      usage: 'dynamic',
      type: 'float',
      length: FLOAT_BYTES * 2, // This buffer is fixed to exactly 1 point consisting of 2 coordinates
    });

    colorTex = createColorTexture();
    encodingTex = createEncodingTexture();

    // Set dimensions
    const whenSet = set({
      backgroundImage,
      width,
      height,
      actionKeyMap,
    });
    updateLassoInitiatorStyle();
    updateLassoLongPressIndicatorStyle();

    // Setup event handler
    window.addEventListener('keyup', keyUpHandler, false);
    window.addEventListener('blur', blurHandler, false);
    window.addEventListener('mouseup', mouseUpHandler, false);
    window.addEventListener('mousemove', mouseMoveHandler, false);
    canvas.addEventListener('mousedown', mouseDownHandler, false);
    canvas.addEventListener('mouseenter', mouseEnterCanvasHandler, false);
    canvas.addEventListener('mouseleave', mouseLeaveCanvasHandler, false);
    canvas.addEventListener('click', mouseClickHandler, false);
    canvas.addEventListener('dblclick', mouseDblClickHandler, false);

    if ('ResizeObserver' in window) {
      canvasObserver = new ResizeObserver(resizeHandler);
      canvasObserver.observe(canvas);
    } else {
      window.addEventListener('resize', resizeHandler);
      window.addEventListener('orientationchange', resizeHandler);
    }

    whenSet.then(() => {
      pubSub.publish('init');
    });
  };

  const cancelFrameListener = renderer.onFrame(() => {
    // Update camera: this needs to happen on every frame
    isViewChanged = camera.tick();

    if (!((isPointsDrawn || isAnnotationsDrawn) && (draw || isTransitioning))) {
      return;
    }

    if (isTransitioning && !tween(transitionDuration, transitionEasing)) {
      endTransition();
    }

    if (isViewChanged) {
      topRightNdc = getScatterGlPos(1, 1);
      bottomLeftNdc = getScatterGlPos(-1, -1);
      if (opacityBy === 'density') {
        getNumPointsInViewDb();
      }
    }

    renderer.render(() => {
      const widthRatio = canvas.width / renderer.canvas.width;
      const heightRatio = canvas.height / renderer.canvas.height;

      updateProjectionMatrix(widthRatio, heightRatio);

      if (backgroundImage?._reglType) {
        drawBackgroundImage();
      }

      if (lassoPointsCurr.length > 2) {
        drawLassoPolygon();
      }

      // The draw order of the following calls is important!
      if (!isTransitioning) {
        pointConnections.draw({
          projection: getProjection(),
          model: getModel(),
          view: getView(),
        });
      }

      const numPoints = getNormalNumPoints();
      if (isPointsDrawn && numPoints > 0) {
        drawPointBodies();
      }

      if (!mouseDown && (showReticle || drawReticleOnce)) {
        drawReticle();
      }

      if (hoveredPoint >= 0) {
        drawHoveredPoint();
      }

      if (selectedPoints.length > 0) {
        drawSelectedPoints();
      }

      annotations.draw({
        projection: getProjection(),
        model: getModel(),
        view: getView(),
      });

      lasso.draw({
        projection: getProjection(),
        model: getModel(),
        view: getView(),
      });
    }, canvas);

    const renderView = {
      view: camera.view,
      isViewChanged,
      camera,
      xScale,
      yScale,
    };

    // Publish camera change
    if (isViewChanged) {
      updateScales();

      if (preventEventView) {
        preventEventView = false;
      } else {
        pubSub.publish('view', renderView);
      }
    }

    draw = false;
    drawReticleOnce = false;

    pubSub.publish('drawing', renderView, { async: false });
    pubSub.publish('draw', renderView);
  });

  const redraw = () => {
    draw = true;
  };

  const destroy = () => {
    isPointsDrawn = false;
    isAnnotationsDrawn = false;
    isDestroyed = true;
    cancelFrameListener();
    window.removeEventListener('keyup', keyUpHandler, false);
    window.removeEventListener('blur', blurHandler, false);
    window.removeEventListener('mouseup', mouseUpHandler, false);
    window.removeEventListener('mousemove', mouseMoveHandler, false);
    canvas.removeEventListener('mousedown', mouseDownHandler, false);
    canvas.removeEventListener('mouseenter', mouseEnterCanvasHandler, false);
    canvas.removeEventListener('mouseleave', mouseLeaveCanvasHandler, false);
    canvas.removeEventListener('click', mouseClickHandler, false);
    canvas.removeEventListener('dblclick', mouseDblClickHandler, false);
    canvas.removeEventListener('wheel', wheelHandler, false);
    if (canvasObserver) {
      canvasObserver.disconnect();
    } else {
      window.removeEventListener('resize', resizeHandler);
      window.removeEventListener('orientationchange', resizeHandler);
    }
    canvas = undefined;
    camera.dispose();
    camera = undefined;
    lasso.destroy();
    lassoManager.destroy();
    pointConnections.destroy();
    reticleHLine.destroy();
    reticleVLine.destroy();
    if (colorTex) {
      colorTex.destroy();
    }
    if (encodingTex) {
      encodingTex.destroy();
    }
    if (!(initialProperties.renderer || renderer.isDestroyed)) {
      // 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.
      // Therefore it's save to destroy it when this scatter plot instance is
      // destroyed.
      renderer.destroy();
    }
    pubSub.publish('destroy');
    pubSub.clear();
  };

  init();

  return {
    /**
     * Get whether the browser supports all necessary WebGL features
     * @return {boolean} If `true` the browser supports all necessary WebGL features
     */
    get isSupported() {
      return renderer.isSupported;
    },
    clear: withDraw(clear),
    clearPoints: withDraw(clearPoints),
    clearPointConnections: withDraw(clearPointConnections),
    clearAnnotations: withDraw(clearAnnotations),
    createTextureFromUrl: (
      /** @type {string} */ url,
      /** @type {number} */ timeout = DEFAULT_IMAGE_LOAD_TIMEOUT,
    ) => createTextureFromUrl(renderer.regl, url, timeout),
    deselect,
    destroy,
    draw: publicDraw,
    drawAnnotations,
    filter,
    get,
    getScreenPosition,
    hover,
    redraw,
    refresh: renderer.refresh,
    reset: withDraw(reset),
    select,
    set,
    export: exportFn,
    subscribe: pubSub.subscribe,
    unfilter,
    unsubscribe: pubSub.unsubscribe,
    view,
    zoomToLocation,
    zoomToArea,
    zoomToPoints,
    zoomToOrigin,
  };
};

export default createScatterplot;

/**
 * Create spatial index from points.
 *
 * @description
 * The spatial index can be used with `scatterplot.draw(points, { spatialIndex })`
 * to drastically speed up the draw call.
 *
 * @param {import('./types').Points} points - The points for which to create the spatial index.
 * @param {boolean=} useWorker - Whether to create the spatial index in a worker thread or not. If `undefined`, the spatial index will be created in a worker if `points` contains more than one million entries.
 * @return {Promise<ArrayBuffer>} Spatial index
 */
const createSpatialIndex = (points, useWorker) =>
  toArrayOrientedPoints(points)
    .then((arrayPoints) => createKdbush(arrayPoints, { useWorker }))
    .then((index) => index.data);

export {
  createRegl,
  createRenderer,
  createSpatialIndex,
  createTextureFromUrl,
  checkSupport,
};