UNPKG

regl-scatterplot

Version:

A WebGL-Powered Scalable Interactive Scatter Plot Library

9,920 lines • 287 kB
import createPubSub from 'pub-sub-es';
import createOriginalRegl from 'regl';

// @flekschas/utils v0.32.2 Copyright 2023 Fritz Lekschas
/* eslint no-param-reassign:0 */

/**
 * Cubic in easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} The eased time
 */
const cubicIn = (t) => t * t * t;

/**
 * Cubic in and out easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} The eased time
 */
const cubicInOut = (t) =>
  t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;

/**
 * Cubic out easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} The eased time
 */
const cubicOut = (t) => --t * t * t + 1;

/**
 * Linear easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} Same as the input
 */
const linear = (t) => t;

/**
 * Quadratic in easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} The eased time
 */
const quadIn = (t) => t * t;

/**
 * Quadratic in and out easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} The eased time
 */
const quadInOut = (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);

/**
 * Quadratic out easing function
 * @param {number} t - The input time to be eased. Must be in [0, 1] where `0`
 *   refers to the start and `1` to the end
 * @return {number} The eased time
 */
const quadOut = (t) => t * (2 - t);

/**
 * Identity function
 * @type {<T>(x: T) => T}
 * @param {*} x - Any kind of value
 * @return {*} `x`
 */
const identity = (x) => x;

/**
 * Check if two arrays contain the same elements
 * @type {<T>(a: T[], b: T[]) => Boolean}
 * @param {array} a - First array
 * @param {array} b - Second array
 * @return {boolean} If `true` the two arrays contain the same elements
 */
const hasSameElements = (a, b) => {
  if (a === b) return true;
  if (a.length !== b.length) return false;
  const aSet = new Set(a);
  const bSet = new Set(b);
  // Since the arrays could contain duplicates, we have to check the set length
  // as well
  if (aSet.size !== bSet.size) return false;
  return b.every((element) => aSet.has(element));
};

/**
 * Vector L2 norm
 *
 * @description
 * This is identical but much faster than `Math.hypot(...v)`
 *
 * @param {number[]} v - Numerical vector
 * @return {number} L2 norm
 */
const l2Norm = (v) => Math.sqrt(v.reduce((sum, x) => sum + x ** 2, 0));

/**
 * Get the maximum number of a vector while ignoring NaNs
 *
 * @description
 * This version is muuuch faster than `Math.max(...v)` and supports vectors
 * longer than 256^2, which is a limitation of `Math.max.apply(null, v)`.
 *
 * @param {number[]} v - Numerical vector
 * @return {number} The largest number
 */
const max$1 = (v) =>
  v.reduce((_max, a) => (a > _max ? a : _max), -Infinity);

/**
 * Initialize an array of a certain length using a mapping function
 *
 * @description
 * This is equivalent to `Array.from({ length }, mapFn)` but about 60% faster
 *
 * @param {number} length - Size of the array
 * @param {function} mapFn - Mapping function
 * @return {array} Initialized array
 * @type {<T = number>(length: number, mapFn: (i: number, length: number) => T) => T[]}
 */
const rangeMap = (length, mapFn = (x) => x) => {
  const out = [];
  for (let i = 0; i < length; i++) {
    out.push(mapFn(i, length));
  }
  return out;
};

/**
 * Get the unique union of two vectors of integers
 * @param {number[]} v - First vector of integers
 * @param {number[]} w - Second vector of integers
 * @return {number[]} Unique union of `v` and `w`
 */
const unionIntegers = (v, w) => {
  const a = [];
  v.forEach((x) => {
    a[x] = true;
  });
  w.forEach((x) => {
    a[x] = true;
  });
  return a.reduce((union, value, i) => {
    if (value) union.push(i);
    return union;
  }, []);
};

/**
 * Assign properties, constructors, etc. to an object
 *
 * @param {object} target - The target object that gets `sources` assigned to it
 * @param {}
 * @return {object}
 */
const assign = (target, ...sources) => {
  sources.forEach((source) => {
    // eslint-disable-next-line no-shadow
    const descriptors = Object.keys(source).reduce((descriptors, key) => {
      descriptors[key] = Object.getOwnPropertyDescriptor(source, key);
      return descriptors;
    }, {});

    // By default, Object.assign copies enumerable Symbols, too
    Object.getOwnPropertySymbols(source).forEach((symbol) => {
      const descriptor = Object.getOwnPropertyDescriptor(source, symbol);
      if (descriptor.enumerable) {
        descriptors[symbol] = descriptor;
      }
    });
    Object.defineProperties(target, descriptors);
  });
  return target;
};

/**
 * Convenience function to compose functions
 * @param {...function} fns - Array of functions
 * @return {function} The composed function
 */
const pipe =
  (...fns) =>
  /**
   * @param {*} x - Some value
   * @return {*} Output of the composed function
   */
  (x) =>
    fns.reduce((y, f) => f(y), x);

/**
 * Assign a constructor to the object
 * @param {function} constructor - Constructor functions
 */
const withConstructor = (constructor) => (self) =>
  assign(
    {
      __proto__: {
        constructor,
      },
    },
    self
  );

/**
 * Assign a static property to an object
 * @param {string} name - Name of the property
 * @param {*} value - Static value
 */
const withStaticProperty = (name, value) => (self) =>
  assign(self, {
    get [name]() {
      return value;
    },
  });

/**
 * L2 distance between a pair of points
 *
 * @description
 * Identical but much faster than `l2Dist([fromX, fromY], [toX, toY])`
 *
 * @param {number} fromX - X coordinate of the first point
 * @param {number} fromY - Y coordinate of the first point
 * @param {number} toX - X coordinate of the second point
 * @param {number} toY - Y coordinate of the first point
 * @return {number} L2 distance
 */
const l2PointDist = (fromX, fromY, toX, toY) =>
  Math.sqrt((fromX - toX) ** 2 + (fromY - toY) ** 2);

/**
 * Create a worker from a function
 * @param {function} fn - Function to be turned into a worker
 * @return {Worker} Worker function
 */
const createWorker$1 = (fn) =>
  new Worker(
    window.URL.createObjectURL(
      new Blob([`(${fn.toString()})()`], { type: 'text/javascript' })
    )
  );

/**
 * Get a promise that resolves after the next `n` animation frames
 * @param {number} n - Number of animation frames to wait
 * @return {Promise} A promise that resolves after the next `n` animation frames
 */
const nextAnimationFrame = (n = 1) =>
  new Promise((resolve) => {
    let i = 0;

    const raf = () =>
      requestAnimationFrame(() => {
        i++;
        if (i < n) raf();
        else resolve();
      });

    raf();
  });

/**
 * Throttle and debounce a function call
 *
 * Throttling a function call means that the function is called at most every
 * `interval` milliseconds no matter how frequently you trigger a call.
 * Debouncing a function call means that the function is called the earliest
 * after `finalWait` milliseconds wait time where the function was not called.
 * Combining the two ensures that the function is called at most every
 * `interval` milliseconds and is ensured to be called with the very latest
 * arguments after after `finalWait` milliseconds wait time at the end.
 *
 * The following imaginary scenario describes the behavior:
 *
 * MS | throttleTime=3 and debounceTime=3
 * 1. y(f, 3, 3)(args1) => f(args1) called
 * 2. y(f, 3, 3)(args2) => call ignored due to throttling
 * 3. y(f, 3, 3)(args3) => call ignored due to throttling
 * 4. y(f, 3, 3)(args4) => f(args4) called
 * 5. y(f, 3, 3)(args5) => all ignored due to throttling
 * 6. No call           => nothing
 * 7. No call           => f(args5) called due to debouncing
 *
 * @param {functon} fn - Function to be throttled and debounced
 * @param {number} throttleTime - Throttle intevals in milliseconds
 * @param {number} debounceTime - Debounce wait time in milliseconds. By default
 *   this is the same as `throttleTime`.
 * @return {function} - Throttled and debounced function
 */
const throttleAndDebounce = (fn, throttleTime, debounceTime = null) => {
  let timeout;
  let blockedCalls = 0;

  // eslint-disable-next-line no-param-reassign
  debounceTime = debounceTime === null ? throttleTime : debounceTime;

  const debounced = (...args) => {
    const later = () => {
      // Since we throttle and debounce we should check whether there were
      // actually multiple attempts to call this function after the most recent
      // throttled call. If there were no more calls we don't have to call
      // the function again.
      if (blockedCalls > 0) {
        fn(...args);
        blockedCalls = 0;
      }
    };

    clearTimeout(timeout);
    timeout = setTimeout(later, debounceTime);
  };

  let isWaiting = false;
  const throttledAndDebounced = (...args) => {
    if (!isWaiting) {
      fn(...args);
      debounced(...args);

      isWaiting = true;
      blockedCalls = 0;

      setTimeout(() => {
        isWaiting = false;
      }, throttleTime);
    } else {
      blockedCalls++;
      debounced(...args);
    }
  };

  throttledAndDebounced.reset = () => {
    isWaiting = false;
  };

  throttledAndDebounced.cancel = () => {
    clearTimeout(timeout);
  };

  throttledAndDebounced.now = (...args) => fn(...args);

  return throttledAndDebounced;
};

/**
 * Common utilities
 * @module glMatrix
 */
// Configuration Constants
var EPSILON = 0.000001;
var ARRAY_TYPE = typeof Float32Array !== 'undefined' ? Float32Array : Array;
if (!Math.hypot) Math.hypot = function () {
  var y = 0,
      i = arguments.length;

  while (i--) {
    y += arguments[i] * arguments[i];
  }

  return Math.sqrt(y);
};

/**
 * 4x4 Matrix<br>Format: column-major, when typed out it looks like row-major<br>The matrices are being post multiplied.
 * @module mat4
 */

/**
 * Creates a new identity mat4
 *
 * @returns {mat4} a new 4x4 matrix
 */

function create$2() {
  var out = new ARRAY_TYPE(16);

  if (ARRAY_TYPE != Float32Array) {
    out[1] = 0;
    out[2] = 0;
    out[3] = 0;
    out[4] = 0;
    out[6] = 0;
    out[7] = 0;
    out[8] = 0;
    out[9] = 0;
    out[11] = 0;
    out[12] = 0;
    out[13] = 0;
    out[14] = 0;
  }

  out[0] = 1;
  out[5] = 1;
  out[10] = 1;
  out[15] = 1;
  return out;
}
/**
 * Creates a new mat4 initialized with values from an existing matrix
 *
 * @param {ReadonlyMat4} a matrix to clone
 * @returns {mat4} a new 4x4 matrix
 */

function clone(a) {
  var out = new ARRAY_TYPE(16);
  out[0] = a[0];
  out[1] = a[1];
  out[2] = a[2];
  out[3] = a[3];
  out[4] = a[4];
  out[5] = a[5];
  out[6] = a[6];
  out[7] = a[7];
  out[8] = a[8];
  out[9] = a[9];
  out[10] = a[10];
  out[11] = a[11];
  out[12] = a[12];
  out[13] = a[13];
  out[14] = a[14];
  out[15] = a[15];
  return out;
}
/**
 * Inverts a mat4
 *
 * @param {mat4} out the receiving matrix
 * @param {ReadonlyMat4} a the source matrix
 * @returns {mat4} out
 */

function invert(out, a) {
  var a00 = a[0],
      a01 = a[1],
      a02 = a[2],
      a03 = a[3];
  var a10 = a[4],
      a11 = a[5],
      a12 = a[6],
      a13 = a[7];
  var a20 = a[8],
      a21 = a[9],
      a22 = a[10],
      a23 = a[11];
  var a30 = a[12],
      a31 = a[13],
      a32 = a[14],
      a33 = a[15];
  var b00 = a00 * a11 - a01 * a10;
  var b01 = a00 * a12 - a02 * a10;
  var b02 = a00 * a13 - a03 * a10;
  var b03 = a01 * a12 - a02 * a11;
  var b04 = a01 * a13 - a03 * a11;
  var b05 = a02 * a13 - a03 * a12;
  var b06 = a20 * a31 - a21 * a30;
  var b07 = a20 * a32 - a22 * a30;
  var b08 = a20 * a33 - a23 * a30;
  var b09 = a21 * a32 - a22 * a31;
  var b10 = a21 * a33 - a23 * a31;
  var b11 = a22 * a33 - a23 * a32; // Calculate the determinant

  var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;

  if (!det) {
    return null;
  }

  det = 1.0 / det;
  out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
  out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
  out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
  out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
  out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
  out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
  out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
  out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
  out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
  out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
  out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
  out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
  out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;
  out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
  out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;
  out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
  return out;
}
/**
 * Multiplies two mat4s
 *
 * @param {mat4} out the receiving matrix
 * @param {ReadonlyMat4} a the first operand
 * @param {ReadonlyMat4} b the second operand
 * @returns {mat4} out
 */

function multiply(out, a, b) {
  var a00 = a[0],
      a01 = a[1],
      a02 = a[2],
      a03 = a[3];
  var a10 = a[4],
      a11 = a[5],
      a12 = a[6],
      a13 = a[7];
  var a20 = a[8],
      a21 = a[9],
      a22 = a[10],
      a23 = a[11];
  var a30 = a[12],
      a31 = a[13],
      a32 = a[14],
      a33 = a[15]; // Cache only the current line of the second matrix

  var b0 = b[0],
      b1 = b[1],
      b2 = b[2],
      b3 = b[3];
  out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
  out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
  out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
  out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
  b0 = b[4];
  b1 = b[5];
  b2 = b[6];
  b3 = b[7];
  out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
  out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
  out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
  out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
  b0 = b[8];
  b1 = b[9];
  b2 = b[10];
  b3 = b[11];
  out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
  out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
  out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
  out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
  b0 = b[12];
  b1 = b[13];
  b2 = b[14];
  b3 = b[15];
  out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
  out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
  out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
  out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
  return out;
}
/**
 * Creates a matrix from a vector translation
 * This is equivalent to (but much faster than):
 *
 *     mat4.identity(dest);
 *     mat4.translate(dest, dest, vec);
 *
 * @param {mat4} out mat4 receiving operation result
 * @param {ReadonlyVec3} v Translation vector
 * @returns {mat4} out
 */

function fromTranslation(out, v) {
  out[0] = 1;
  out[1] = 0;
  out[2] = 0;
  out[3] = 0;
  out[4] = 0;
  out[5] = 1;
  out[6] = 0;
  out[7] = 0;
  out[8] = 0;
  out[9] = 0;
  out[10] = 1;
  out[11] = 0;
  out[12] = v[0];
  out[13] = v[1];
  out[14] = v[2];
  out[15] = 1;
  return out;
}
/**
 * Creates a matrix from a vector scaling
 * This is equivalent to (but much faster than):
 *
 *     mat4.identity(dest);
 *     mat4.scale(dest, dest, vec);
 *
 * @param {mat4} out mat4 receiving operation result
 * @param {ReadonlyVec3} v Scaling vector
 * @returns {mat4} out
 */

function fromScaling(out, v) {
  out[0] = v[0];
  out[1] = 0;
  out[2] = 0;
  out[3] = 0;
  out[4] = 0;
  out[5] = v[1];
  out[6] = 0;
  out[7] = 0;
  out[8] = 0;
  out[9] = 0;
  out[10] = v[2];
  out[11] = 0;
  out[12] = 0;
  out[13] = 0;
  out[14] = 0;
  out[15] = 1;
  return out;
}
/**
 * Creates a matrix from a given angle around a given axis
 * This is equivalent to (but much faster than):
 *
 *     mat4.identity(dest);
 *     mat4.rotate(dest, dest, rad, axis);
 *
 * @param {mat4} out mat4 receiving operation result
 * @param {Number} rad the angle to rotate the matrix by
 * @param {ReadonlyVec3} axis the axis to rotate around
 * @returns {mat4} out
 */

function fromRotation(out, rad, axis) {
  var x = axis[0],
      y = axis[1],
      z = axis[2];
  var len = Math.hypot(x, y, z);
  var s, c, t;

  if (len < EPSILON) {
    return null;
  }

  len = 1 / len;
  x *= len;
  y *= len;
  z *= len;
  s = Math.sin(rad);
  c = Math.cos(rad);
  t = 1 - c; // Perform rotation-specific matrix multiplication

  out[0] = x * x * t + c;
  out[1] = y * x * t + z * s;
  out[2] = z * x * t - y * s;
  out[3] = 0;
  out[4] = x * y * t - z * s;
  out[5] = y * y * t + c;
  out[6] = z * y * t + x * s;
  out[7] = 0;
  out[8] = x * z * t + y * s;
  out[9] = y * z * t - x * s;
  out[10] = z * z * t + c;
  out[11] = 0;
  out[12] = 0;
  out[13] = 0;
  out[14] = 0;
  out[15] = 1;
  return out;
}
/**
 * Returns the translation vector component of a transformation
 *  matrix. If a matrix is built with fromRotationTranslation,
 *  the returned vector will be the same as the translation vector
 *  originally supplied.
 * @param  {vec3} out Vector to receive translation component
 * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)
 * @return {vec3} out
 */

function getTranslation(out, mat) {
  out[0] = mat[12];
  out[1] = mat[13];
  out[2] = mat[14];
  return out;
}
/**
 * Returns the scaling factor component of a transformation
 *  matrix. If a matrix is built with fromRotationTranslationScale
 *  with a normalized Quaternion paramter, the returned vector will be
 *  the same as the scaling vector
 *  originally supplied.
 * @param  {vec3} out Vector to receive scaling factor component
 * @param  {ReadonlyMat4} mat Matrix to be decomposed (input)
 * @return {vec3} out
 */

function getScaling(out, mat) {
  var m11 = mat[0];
  var m12 = mat[1];
  var m13 = mat[2];
  var m21 = mat[4];
  var m22 = mat[5];
  var m23 = mat[6];
  var m31 = mat[8];
  var m32 = mat[9];
  var m33 = mat[10];
  out[0] = Math.hypot(m11, m12, m13);
  out[1] = Math.hypot(m21, m22, m23);
  out[2] = Math.hypot(m31, m32, m33);
  return out;
}

/**
 * 4 Dimensional Vector
 * @module vec4
 */

/**
 * Creates a new, empty vec4
 *
 * @returns {vec4} a new 4D vector
 */

function create$1() {
  var out = new ARRAY_TYPE(4);

  if (ARRAY_TYPE != Float32Array) {
    out[0] = 0;
    out[1] = 0;
    out[2] = 0;
    out[3] = 0;
  }

  return out;
}
/**
 * Transforms the vec4 with a mat4.
 *
 * @param {vec4} out the receiving vector
 * @param {ReadonlyVec4} a the vector to transform
 * @param {ReadonlyMat4} m matrix to transform with
 * @returns {vec4} out
 */

function transformMat4(out, a, m) {
  var x = a[0],
      y = a[1],
      z = a[2],
      w = a[3];
  out[0] = m[0] * x + m[4] * y + m[8] * z + m[12] * w;
  out[1] = m[1] * x + m[5] * y + m[9] * z + m[13] * w;
  out[2] = m[2] * x + m[6] * y + m[10] * z + m[14] * w;
  out[3] = m[3] * x + m[7] * y + m[11] * z + m[15] * w;
  return out;
}
/**
 * Perform some operation over an array of vec4s.
 *
 * @param {Array} a the array of vectors to iterate over
 * @param {Number} stride Number of elements between the start of each vec4. If 0 assumes tightly packed
 * @param {Number} offset Number of elements to skip at the beginning of the array
 * @param {Number} count Number of vec4s to iterate over. If 0 iterates over entire array
 * @param {Function} fn Function to call for each vector in the array
 * @param {Object} [arg] additional argument to pass to fn
 * @returns {Array} a
 * @function
 */

(function () {
  var vec = create$1();
  return function (a, stride, offset, count, fn, arg) {
    var i, l;

    if (!stride) {
      stride = 4;
    }

    if (!offset) {
      offset = 0;
    }

    if (count) {
      l = Math.min(count * stride + offset, a.length);
    } else {
      l = a.length;
    }

    for (i = offset; i < l; i += stride) {
      vec[0] = a[i];
      vec[1] = a[i + 1];
      vec[2] = a[i + 2];
      vec[3] = a[i + 3];
      fn(vec, vec, arg);
      a[i] = vec[0];
      a[i + 1] = vec[1];
      a[i + 2] = vec[2];
      a[i + 3] = vec[3];
    }

    return a;
  };
})();

/**
 * 2 Dimensional Vector
 * @module vec2
 */

/**
 * Creates a new, empty vec2
 *
 * @returns {vec2} a new 2D vector
 */

function create() {
  var out = new ARRAY_TYPE(2);

  if (ARRAY_TYPE != Float32Array) {
    out[0] = 0;
    out[1] = 0;
  }

  return out;
}
/**
 * Get the angle between two 2D vectors
 * @param {ReadonlyVec2} a The first operand
 * @param {ReadonlyVec2} b The second operand
 * @returns {Number} The angle in radians
 */

function angle(a, b) {
  var x1 = a[0],
      y1 = a[1],
      x2 = b[0],
      y2 = b[1],
      // mag is the product of the magnitudes of a and b
  mag = Math.sqrt(x1 * x1 + y1 * y1) * Math.sqrt(x2 * x2 + y2 * y2),
      // mag &&.. short circuits if mag == 0
  cosine = mag && (x1 * x2 + y1 * y2) / mag; // Math.min(Math.max(cosine, -1), 1) clamps the cosine between -1 and 1

  return Math.acos(Math.min(Math.max(cosine, -1), 1));
}
/**
 * Perform some operation over an array of vec2s.
 *
 * @param {Array} a the array of vectors to iterate over
 * @param {Number} stride Number of elements between the start of each vec2. If 0 assumes tightly packed
 * @param {Number} offset Number of elements to skip at the beginning of the array
 * @param {Number} count Number of vec2s to iterate over. If 0 iterates over entire array
 * @param {Function} fn Function to call for each vector in the array
 * @param {Object} [arg] additional argument to pass to fn
 * @returns {Array} a
 * @function
 */

(function () {
  var vec = create();
  return function (a, stride, offset, count, fn, arg) {
    var i, l;

    if (!stride) {
      stride = 2;
    }

    if (!offset) {
      offset = 0;
    }

    if (count) {
      l = Math.min(count * stride + offset, a.length);
    } else {
      l = a.length;
    }

    for (i = offset; i < l; i += stride) {
      vec[0] = a[i];
      vec[1] = a[i + 1];
      fn(vec, vec, arg);
      a[i] = vec[0];
      a[i + 1] = vec[1];
    }

    return a;
  };
})();

const createCamera = (
  initTarget = [0, 0],
  initDistance = 1,
  initRotation = 0,
  initViewCenter = [0, 0],
  initScaleBounds = [
    [0, Infinity],
    [0, Infinity],
  ],
  initTranslationBounds = [
    [-Infinity, Infinity],
    [-Infinity, Infinity],
  ]
) => {
  // Scratch variables
  const scratch0 = new Float32Array(16);
  const scratch1 = new Float32Array(16);
  const scratch2 = new Float32Array(16);

  let view = create$2();
  let viewCenter = [...initViewCenter.slice(0, 2), 0, 1];

  const scaleXBounds = Array.isArray(initScaleBounds[0])
    ? [...initScaleBounds[0]]
    : [...initScaleBounds];
  const scaleYBounds = Array.isArray(initScaleBounds[0])
    ? [...initScaleBounds[1]]
    : [...initScaleBounds];

  const translationXBounds = Array.isArray(initTranslationBounds[0])
    ? [...initTranslationBounds[0]]
    : [...initTranslationBounds];
  const translationYBounds = Array.isArray(initTranslationBounds[0])
    ? [...initTranslationBounds[1]]
    : [...initTranslationBounds];

  const getScaling$1 = () => getScaling(scratch0, view).slice(0, 2);
  const getMinScaling = () => {
    const scaling = getScaling$1();
    return Math.min(scaling[0], scaling[1]);
  };
  const getMaxScaling = () => {
    const scaling = getScaling$1();
    return Math.max(scaling[0], scaling[1]);
  };

  const getRotation = () => Math.acos(view[0] / getMaxScaling());

  const getScaleBounds = () => [[...scaleXBounds], [...scaleYBounds]];
  const getTranslationBounds = () => [
    [...translationXBounds],
    [...translationYBounds],
  ];

  const getDistance = () => {
    const scaling = getScaling$1();
    return [1 / scaling[0], 1 / scaling[1]];
  };
  const getMinDistance = () => 1 / getMinScaling();
  const getMaxDistance = () => 1 / getMaxScaling();

  const getTranslation$1 = () => getTranslation(scratch0, view).slice(0, 2);

  const getTarget = () =>
    transformMat4(scratch0, viewCenter, invert(scratch2, view))
      .slice(0, 2);

  const getView = () => view;

  const getViewCenter = () => viewCenter.slice(0, 2);

  const lookAt = ([x = 0, y = 0] = [], newDistance = 1, newRotation = 0) => {
    // Reset the view
    view = create$2();

    translate([-x, -y]);
    rotate(newRotation);
    scale(1 / newDistance);
  };

  const translate = ([x = 0, y = 0] = []) => {
    scratch0[0] = x;
    scratch0[1] = y;
    scratch0[2] = 0;

    const t = fromTranslation(scratch1, scratch0);

    // Translate about the viewport center
    // This is identical to `i * t * i * view` where `i` is the identity matrix
    multiply(view, t, view);
  };

  const scale = (d, mousePos) => {
    const isArray = Array.isArray(d);
    let dx = isArray ? d[0] : d;
    let dy = isArray ? d[1] : d;

    if (dx <= 0 || dy <= 0 || (dx === 1 && dy === 1)) return;

    const scaling = getScaling$1();
    const newXScale = scaling[0] * dx;
    const newYScale = scaling[1] * dy;

    dx =
      Math.max(scaleXBounds[0], Math.min(newXScale, scaleXBounds[1])) /
      scaling[0];
    dy =
      Math.max(scaleYBounds[0], Math.min(newYScale, scaleYBounds[1])) /
      scaling[1];

    if (dx === 1 && dy === 1) return; // There is nothing to do

    scratch0[0] = dx;
    scratch0[1] = dy;
    scratch0[2] = 1;

    const s = fromScaling(scratch1, scratch0);

    const scaleCenter = mousePos ? [...mousePos, 0] : viewCenter;
    const a = fromTranslation(scratch0, scaleCenter);

    // Translate about the scale center
    // I.e., the mouse position or the view center
    multiply(
      view,
      a,
      multiply(
        view,
        s,
        multiply(view, invert(scratch2, a), view)
      )
    );
  };

  const rotate = (rad) => {
    const r = create$2();
    fromRotation(r, rad, [0, 0, 1]);

    // Rotate about the viewport center
    // This is identical to `i * r * i * view` where `i` is the identity matrix
    multiply(view, r, view);
  };

  const setScaleBounds = (newBounds) => {
    const isArray = Array.isArray(newBounds[0]);
    scaleXBounds[0] = isArray ? newBounds[0][0] : newBounds[0];
    scaleXBounds[1] = isArray ? newBounds[0][1] : newBounds[1];
    scaleYBounds[0] = isArray ? newBounds[1][0] : newBounds[0];
    scaleYBounds[1] = isArray ? newBounds[1][1] : newBounds[1];
  };

  const setTranslationBounds = (newBounds) => {
    const isArray = Array.isArray(newBounds[0]);
    translationXBounds[0] = isArray ? newBounds[0][0] : newBounds[0];
    translationXBounds[1] = isArray ? newBounds[0][1] : newBounds[1];
    translationYBounds[0] = isArray ? newBounds[1][0] : newBounds[0];
    translationYBounds[1] = isArray ? newBounds[1][1] : newBounds[1];
  };

  const setView = (newView) => {
    if (!newView || newView.length < 16) return;
    view = newView;
  };

  const setViewCenter = (newViewCenter) => {
    viewCenter = [...newViewCenter.slice(0, 2), 0, 1];
  };

  const reset = () => {
    lookAt(initTarget, initDistance, initRotation);
  };

  // Init
  lookAt(initTarget, initDistance, initRotation);

  return {
    get translation() {
      return getTranslation$1();
    },
    get target() {
      return getTarget();
    },
    get scaling() {
      return getScaling$1();
    },
    get minScaling() {
      return getMinScaling();
    },
    get maxScaling() {
      return getMaxScaling();
    },
    get scaleBounds() {
      return getScaleBounds();
    },
    get translationBounds() {
      return getTranslationBounds();
    },
    get distance() {
      return getDistance();
    },
    get minDistance() {
      return getMinDistance();
    },
    get maxDistance() {
      return getMaxDistance();
    },
    get rotation() {
      return getRotation();
    },
    get view() {
      return getView();
    },
    get viewCenter() {
      return getViewCenter();
    },
    lookAt,
    translate,
    pan: translate,
    rotate,
    scale,
    zoom: scale,
    reset,
    set: (...args) => {
      console.warn('`set()` is deprecated. Please use `setView()` instead.');
      return setView(...args);
    },
    setScaleBounds,
    setTranslationBounds,
    setView,
    setViewCenter,
  };
};

const MOUSE_DOWN_MOVE_ACTIONS = ["pan", "rotate"];
const KEY_MAP = {
  alt: "altKey",
  cmd: "metaKey",
  ctrl: "ctrlKey",
  meta: "metaKey",
  shift: "shiftKey"
};

const dom2dCamera = (
  element,
  {
    distance = 1.0,
    target = [0, 0],
    rotation = 0,
    isNdc = true,
    isFixed = false,
    isPan = true,
    isPanInverted = [false, true],
    panSpeed = 1,
    isRotate = true,
    rotateSpeed = 1,
    defaultMouseDownMoveAction = "pan",
    mouseDownMoveModKey = "alt",
    isZoom = true,
    zoomSpeed = 1,
    viewCenter,
    scaleBounds,
    translationBounds,
    onKeyDown = () => {},
    onKeyUp = () => {},
    onMouseDown = () => {},
    onMouseUp = () => {},
    onMouseMove = () => {},
    onWheel = () => {}
  } = {}
) => {
  let camera = createCamera(
    target,
    distance,
    rotation,
    viewCenter,
    scaleBounds,
    translationBounds
  );
  let mouseX = 0;
  let mouseY = 0;
  let mouseRelX = 0;
  let mouseRelY = 0;
  let prevMouseX = 0;
  let prevMouseY = 0;
  let isLeftMousePressed = false;
  let scrollDist = 0;

  let width = 1;
  let height = 1;
  let aspectRatio = 1;

  let isInteractivelyChanged = false;
  let isProgrammaticallyChanged = false;
  let isMouseDownMoveModActive = false;

  let panOnMouseDownMove = defaultMouseDownMoveAction === "pan";

  let isPanX = isPan;
  let isPanY = isPan;
  let isPanXInverted = isPanInverted;
  let isPanYInverted = isPanInverted;
  let isZoomX = isZoom;
  let isZoomY = isZoom;

  const spreadXYSettings = () => {
    isPanX = Array.isArray(isPan) ? Boolean(isPan[0]) : isPan;
    isPanY = Array.isArray(isPan) ? Boolean(isPan[1]) : isPan;
    isPanXInverted = Array.isArray(isPanInverted)
      ? Boolean(isPanInverted[0])
      : isPanInverted;
    isPanYInverted = Array.isArray(isPanInverted)
      ? Boolean(isPanInverted[1])
      : isPanInverted;
    isZoomX = Array.isArray(isZoom) ? Boolean(isZoom[0]) : isZoom;
    isZoomY = Array.isArray(isZoom) ? Boolean(isZoom[1]) : isZoom;
  };

  spreadXYSettings();

  const transformPanX = isNdc
    ? dX => (dX / width) * 2 * aspectRatio // to normalized device coords
    : dX => dX;
  const transformPanY = isNdc
    ? dY => (dY / height) * 2 // to normalized device coords
    : dY => -dY;

  const transformScaleX = isNdc
    ? x => (-1 + (x / width) * 2) * aspectRatio // to normalized device coords
    : x => x;
  const transformScaleY = isNdc
    ? y => 1 - (y / height) * 2 // to normalized device coords
    : y => y;

  const tick = () => {
    if (isFixed) {
      const isChanged = isProgrammaticallyChanged;
      isProgrammaticallyChanged = false;
      return isChanged;
    }

    isInteractivelyChanged = false;
    const currentMouseX = mouseX;
    const currentMouseY = mouseY;

    if (
      (isPanX || isPanY) &&
      isLeftMousePressed &&
      ((panOnMouseDownMove && !isMouseDownMoveModActive) ||
        (!panOnMouseDownMove && isMouseDownMoveModActive))
    ) {
      const dX = isPanXInverted
        ? prevMouseX - currentMouseX
        : currentMouseX - prevMouseX;

      const transformedPanX = isPanX ? transformPanX(panSpeed * dX) : 0;

      const dY = isPanYInverted
        ? prevMouseY - currentMouseY
        : currentMouseY - prevMouseY;

      const transformedPanY = isPanY ? transformPanY(panSpeed * dY) : 0;

      if (transformedPanX !== 0 || transformedPanY !== 0) {
        camera.pan([transformedPanX, transformedPanY]);
        isInteractivelyChanged = true;
      }
    }

    if ((isZoomX || isZoomY) && scrollDist) {
      const dZ = zoomSpeed * Math.exp(scrollDist / height);

      const transformedX = transformScaleX(mouseRelX);
      const transformedY = transformScaleY(mouseRelY);

      camera.scale(
        [isZoomX ? 1 / dZ : 1, isZoomY ? 1 / dZ : 1],
        [transformedX, transformedY]
      );

      isInteractivelyChanged = true;
    }

    if (
      isRotate &&
      isLeftMousePressed &&
      ((panOnMouseDownMove && isMouseDownMoveModActive) ||
        (!panOnMouseDownMove && !isMouseDownMoveModActive)) &&
      Math.abs(prevMouseX - currentMouseX) +
        Math.abs(prevMouseY - currentMouseY) >
        0
    ) {
      const wh = width / 2;
      const hh = height / 2;
      const x1 = prevMouseX - wh;
      const y1 = hh - prevMouseY;
      const x2 = currentMouseX - wh;
      const y2 = hh - currentMouseY;
      // Angle between the start and end mouse position with respect to the
      // viewport center
      const radians = angle([x1, y1], [x2, y2]);
      // Determine the orientation
      const cross = x1 * y2 - x2 * y1;

      camera.rotate(rotateSpeed * radians * Math.sign(cross));

      isInteractivelyChanged = true;
    }

    // Reset scroll delta and mouse position
    scrollDist = 0;
    prevMouseX = currentMouseX;
    prevMouseY = currentMouseY;

    const isChanged = isInteractivelyChanged || isProgrammaticallyChanged;

    isProgrammaticallyChanged = false;

    return isChanged;
  };

  const config = ({
    defaultMouseDownMoveAction: newDefaultMouseDownMoveAction = null,
    isFixed: newIsFixed = null,
    isPan: newIsPan = null,
    isPanInverted: newIsPanInverted = null,
    isRotate: newIsRotate = null,
    isZoom: newIsZoom = null,
    panSpeed: newPanSpeed = null,
    rotateSpeed: newRotateSpeed = null,
    zoomSpeed: newZoomSpeed = null,
    mouseDownMoveModKey: newMouseDownMoveModKey = null
  } = {}) => {
    defaultMouseDownMoveAction =
      newDefaultMouseDownMoveAction !== null &&
      MOUSE_DOWN_MOVE_ACTIONS.includes(newDefaultMouseDownMoveAction)
        ? newDefaultMouseDownMoveAction
        : defaultMouseDownMoveAction;

    panOnMouseDownMove = defaultMouseDownMoveAction === "pan";

    isFixed = newIsFixed !== null ? newIsFixed : isFixed;
    isPan = newIsPan !== null ? newIsPan : isPan;
    isPanInverted =
      newIsPanInverted !== null ? newIsPanInverted : isPanInverted;
    isRotate = newIsRotate !== null ? newIsRotate : isRotate;
    isZoom = newIsZoom !== null ? newIsZoom : isZoom;
    panSpeed = +newPanSpeed > 0 ? newPanSpeed : panSpeed;
    rotateSpeed = +newRotateSpeed > 0 ? newRotateSpeed : rotateSpeed;
    zoomSpeed = +newZoomSpeed > 0 ? newZoomSpeed : zoomSpeed;

    spreadXYSettings();

    mouseDownMoveModKey =
      newMouseDownMoveModKey !== null &&
      Object.keys(KEY_MAP).includes(newMouseDownMoveModKey)
        ? newMouseDownMoveModKey
        : mouseDownMoveModKey;
  };

  const refresh = () => {
    const bBox = element.getBoundingClientRect();
    width = bBox.width;
    height = bBox.height;
    aspectRatio = width / height;
  };

  const keyUpHandler = event => {
    isMouseDownMoveModActive = false;

    onKeyUp(event);
  };

  const keyDownHandler = event => {
    isMouseDownMoveModActive = event[KEY_MAP[mouseDownMoveModKey]];

    onKeyDown(event);
  };

  const mouseUpHandler = event => {
    isLeftMousePressed = false;

    onMouseUp(event);
  };

  const mouseDownHandler = event => {
    isLeftMousePressed = event.buttons === 1;

    onMouseDown(event);
  };

  const offsetXSupport =
    document.createEvent("MouseEvent").offsetX !== undefined;

  const updateMouseRelXY = offsetXSupport
    ? event => {
        mouseRelX = event.offsetX;
        mouseRelY = event.offsetY;
      }
    : event => {
        const bBox = element.getBoundingClientRect();
        mouseRelX = event.clientX - bBox.left;
        mouseRelY = event.clientY - bBox.top;
      };

  const updateMouseXY = event => {
    mouseX = event.clientX;
    mouseY = event.clientY;
  };

  const mouseMoveHandler = event => {
    updateMouseXY(event);
    onMouseMove(event);
  };

  const wheelHandler = event => {
    if ((isZoomX || isZoomY) && !isFixed) {
      event.preventDefault();

      updateMouseXY(event);
      updateMouseRelXY(event);

      const scale = event.deltaMode === 1 ? 12 : 1;

      scrollDist += scale * (event.deltaY || event.deltaX || 0);
    }

    onWheel(event);
  };

  const dispose = () => {
    camera = undefined;
    window.removeEventListener("keydown", keyDownHandler);
    window.removeEventListener("keyup", keyUpHandler);
    element.removeEventListener("mousedown", mouseDownHandler);
    window.removeEventListener("mouseup", mouseUpHandler);
    window.removeEventListener("mousemove", mouseMoveHandler);
    element.removeEventListener("wheel", wheelHandler);
  };

  window.addEventListener("keydown", keyDownHandler, { passive: true });
  window.addEventListener("keyup", keyUpHandler, { passive: true });
  element.addEventListener("mousedown", mouseDownHandler, { passive: true });
  window.addEventListener("mouseup", mouseUpHandler, { passive: true });
  window.addEventListener("mousemove", mouseMoveHandler, { passive: true });
  element.addEventListener("wheel", wheelHandler, { passive: false });

  camera.config = config;
  camera.dispose = dispose;
  camera.refresh = refresh;
  camera.tick = tick;

  const withProgrammaticChange = fn =>
    function() {
      fn.apply(null, arguments);
      isProgrammaticallyChanged = true;
    };

  camera.lookAt = withProgrammaticChange(camera.lookAt);
  camera.translate = withProgrammaticChange(camera.translate);
  camera.pan = withProgrammaticChange(camera.pan);
  camera.rotate = withProgrammaticChange(camera.rotate);
  camera.scale = withProgrammaticChange(camera.scale);
  camera.zoom = withProgrammaticChange(camera.zoom);
  camera.reset = withProgrammaticChange(camera.reset);
  camera.set = withProgrammaticChange(camera.set);
  camera.setScaleBounds = withProgrammaticChange(camera.setScaleBounds);
  camera.setTranslationBounds = withProgrammaticChange(
    camera.setTranslationBounds
  );
  camera.setView = withProgrammaticChange(camera.setView);
  camera.setViewCenter = withProgrammaticChange(camera.setViewCenter);

  refresh();

  return camera;
};

function earcut(data, holeIndices, dim = 2) {
    const outerLen = data.length;
    let outerNode = linkedList(data, 0, outerLen, dim, true);
    const triangles = [];

    if (!outerNode || outerNode.next === outerNode.prev) return triangles;

    let minX, minY, invSize;

    // if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox
    if (data.length > 80 * dim) {
        minX = Infinity;
        minY = Infinity;
        let maxX = -Infinity;
        let maxY = -Infinity;

        for (let i = dim; i < outerLen; i += dim) {
            const x = data[i];
            const y = data[i + 1];
            if (x < minX) minX = x;
            if (y < minY) minY = y;
            if (x > maxX) maxX = x;
            if (y > maxY) maxY = y;
        }

        // minX, minY and invSize are later used to transform coords into integers for z-order calculation
        invSize = Math.max(maxX - minX, maxY - minY);
        invSize = invSize !== 0 ? 32767 / invSize : 0;
    }

    earcutLinked(outerNode, triangles, dim, minX, minY, invSize, 0);

    return triangles;
}

// create a circular doubly linked list from polygon points in the specified winding order
function linkedList(data, start, end, dim, clockwise) {
    let last;

    if (clockwise === (signedArea(data, start, end, dim) > 0)) {
        for (let i = start; i < end; i += dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
    } else {
        for (let i = end - dim; i >= start; i -= dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
    }

    if (last && equals(last, last.next)) {
        removeNode(last);
        last = last.next;
    }

    return last;
}

// eliminate colinear or duplicate points
function filterPoints(start, end) {
    if (!start) return start;
    if (!end) end = start;

    let p = start,
        again;
    do {
        again = false;

        if (!p.steiner && (equals(p, p.next) || area(p.prev, p, p.next) === 0)) {
            removeNode(p);
            p = end = p.prev;
            if (p === p.next) break;
            again = true;

        } else {
            p = p.next;
        }
    } while (again || p !== end);

    return end;
}

// main ear slicing loop which triangulates a polygon (given as a linked list)
function earcutLinked(ear, triangles, dim, minX, minY, invSize, pass) {
    if (!ear) return;

    // interlink polygon nodes in z-order
    if (!pass && invSize) indexCurve(ear, minX, minY, invSize);

    let stop = ear;

    // iterate through ears, slicing them one by one
    while (ear.prev !== ear.next) {
        const prev = ear.prev;
        const next = ear.next;

        if (invSize ? isEarHashed(ear, minX, minY, invSize) : isEar(ear)) {
            triangles.push(prev.i, ear.i, next.i); // cut off the triangle

            removeNode(ear);

            // skipping the next vertex leads to less sliver triangles
            ear = next.next;
            stop = next.next;

            continue;
        }

        ear = next;

        // if we looped through the whole remaining polygon and can't find any more ears
        if (ear === stop) {
            // try filtering points and slicing again
            if (!pass) {
                earcutLinked(filterPoints(ear), triangles, dim, minX, minY, invSize, 1);

            // if this didn't work, try curing all small self-intersections locally
            } else if (pass === 1) {
                ear = cureLocalIntersections(filterPoints(ear), triangles);
                earcutLinked(ear, triangles, dim, minX, minY, invSize, 2);

            // as a last resort, try splitting the remaining polygon into two
            } else if (pass === 2) {
                splitEarcut(ear, triangles, dim, minX, minY, invSize);
            }

            break;
        }
    }
}

// check whether a polygon node forms a valid ear with adjacent nodes
function isEar(ear) {
    const a = ear.prev,
        b = ear,
        c = ear.next;

    if (area(a, b, c) >= 0) return false; // reflex, can't be an ear

    // now make sure we don't have other points inside the potential ear
    const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;

    // triangle bbox
    const x0 = Math.min(ax, bx, cx),
        y0 = Math.min(ay, by, cy),
        x1 = Math.max(ax, bx, cx),
        y1 = Math.max(ay, by, cy);

    let p = c.next;
    while (p !== a) {
        if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 &&
            pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) &&
            area(p.prev, p, p.next) >= 0) return false;
        p = p.next;
    }

    return true;
}

function isEarHashed(ear, minX, minY, invSize) {
    const a = ear.prev,
        b = ear,
        c = ear.next;

    if (area(a, b, c) >= 0) return false; // reflex, can't be an ear

    const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;

    // triangle bbox
    const x0 = Math.min(ax, bx, cx),
        y0 = Math.min(ay, by, cy),
        x1 = Math.max(ax, bx, cx),
        y1 = Math.max(ay, by, cy);

    // z-order range for the current triangle bbox;
    const minZ = zOrder(x0, y0, minX, minY, invSize),
        maxZ = zOrder(x1, y1, minX, minY, invSize);

    let p = ear.prevZ,
        n = ear.nextZ;

    // look for points inside the triangle in both directions
    while (p && p.z >= minZ && n && n.z <= maxZ) {
        if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
            pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
        p = p.prevZ;

        if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
            pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
        n = n.nextZ;
    }

    // look for remaining points in decreasing z-order
    while (p && p.z >= minZ) {
        if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
            pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
        p = p.prevZ;
    }

    // look for remaining points in increasing z-order
    while (n && n.z <= maxZ) {
        if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
            pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
        n = n.nextZ;
    }

    return true;
}

// go through all polygon nodes and cure small local self-intersections
function cureLocalIntersections(start, triangles) {
    let p = start;
    do {
        const a = p.prev,
            b = p.next.next;

        if (!equals(a, b) && intersects(a, p, p.next, b) && locallyInside(a, b) && locallyInside(b, a)) {

            triangles.push(a.i, p.i, b.i);

            // remove two nodes involved
            removeNode(p);
            removeNode(p.next);

            p = start = b;
        }
        p = p.next;
    } while (p !== start);

    return filterPoints(p);
}

// try splitting polygon into two and triangulate them independently
function splitEarcut(start, triangles, dim, minX, minY, invSize) {
    // look for a valid diagonal that divides the polygon into two
    let a = start;
    do {
        let b = a.next.next;
        while (b !== a.prev) {
            if (a.i !== b.i && isValidDiagonal(a, b)) {
                // split the polygon in two by the diagonal
                let c = splitPolygon(a, b);

                // filter colinear points around the cuts
                a = filterPoints(a, a.next);
                c = filterPoints(c, c.next);

                // run earcut on each half
                earcutLinked(a, triangles, dim, minX, minY, invSize, 0);
                earcutLinked(c, triangles, dim, minX, minY, invSize, 0);
                return;
            }
            b = b.next;
        }
        a = a.next;
    } while (a !== start);
}

// interlink polygon nodes in z-order
function indexCurve(start, minX, minY, invSize) {
    let p = start;
    do {
        if (p.z === 0) p.z = zOrder(p.x, p.y, minX, minY, invSize);
        p.prevZ = p.prev;
        p.nextZ = p.next;
        p = p.next;
    } while (p !== start);

    p.prevZ.nextZ = null;
    p.prevZ = null;

    sortLinked(p);
}

// Simon Tatham's linked list merge sort algorithm
// http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html
function sortLinked(list) {
    let numMerges;
    let inSize = 1;

    do {
        let p = list;
        let e;
        list = null;
        let tail = null;
        numMerges = 0;

        while (p) {
            numMerges++;
            let q = p;
            let pSize = 0;
            for (let i = 0; i < inSize; i++) {
                pSize++;
                q = q.nextZ;
                if (!q) break;
            }
            let qSize = inSize;

            while (pSize > 0 || (qSize > 0 && q)) {

                if (pSize !== 0 && (qSize === 0 || !q || p.z <= q.z)) {
                    e = p;
                    p = p.nextZ;
                    pSize--;
                } else {
                    e = q;
                    q = q.nextZ;
                    qSize--;
                }

                if (tail) tail.nextZ = e;
                else list = e;

                e.prevZ = tail;
                tail = e;
            }

            p = q;
        }

        tail.nextZ = null;
        inSize *= 2;

    } while (numMerges > 1);

    return list;
}

// z-order of a point given coords and inverse of the longer side of data bbox
function zOrder(x, y, minX, minY, invSize) {
    // coords are transformed into non-negative 15-bit integer range
    x = (x - minX) * invSize | 0;
    y = (y - minY) * invSize | 0;

    x = (x | (x << 8)) & 0x00FF00FF;
    x = (x | (x << 4)) & 0x0F0F0F0F;
    x = (x | (x << 2)) & 0x33333333;
    x = (x | (x << 1)) & 0x55555555;

    y = (y | (y << 8)) & 0x00FF00FF;
    y = (y | (y << 4)) & 0x0F0F0F0F;
    y = (y | (y << 2)) & 0x33333333;
    y = (y | (y << 1)) & 0x55555555;

    return x | (y << 1);
}

// check if a point lies within a convex triangle
function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
    return (cx - px) * (ay - py) >= (ax - px) * (cy - py) &&
           (ax - px) * (by - py) >= (bx - px) * (ay - py) &&
           (bx - px) * (cy - py) >= (cx - px) * (by - py);
}

// check if a point lies within a convex triangle but false if its equal to the first point of the triangle
function pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, px, py) {
    return !(ax === px && ay === py) && pointInTriangle(ax, ay, bx, by, cx, cy, px, py);
}

// check if a diagonal between two polygon nodes is valid (lies in polygon interior)
function isValidDiagonal(a, b) {
    return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && // dones't intersect other edges
           (locallyInside(a, b) && locallyInside(b, a) && middleInside(a, b) && // locally visible
            (area(a.prev, a, b.prev) || area(a, b.prev, b)) || // does not create opposite-facing sectors
            equals(a, b) && area(a.prev, a, a.next) > 0 && area(b.prev, b, b.next) > 0); // special zero-length case
}

// signed area of a triangle
function area(p, q, r) {
    return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
}

// check if two points are equal
function equals(p1, p2) {
    return p1.x === p2.x && p1.y === p2.y;
}

// check if two segments intersect
function intersects(p1, q1, p2, q2) {
    const o1 = sign(area(p1, q1, p2));
    const o2 = sign(area(p1, q1, q2));
    const o3 = sign(area(p2, q2, p1));
    const o4 = sign(area(p2, q2, q1));

    if (o1 !== o2 && o3 !== o4) return true; // general case

    if (o1 === 0 && onSegment(p1, p2, q1)) return true; // p1, q1 and p2 are collinear and p2 lies on p1q1
    if (o2 === 0 && onSegment(p1, q2, q1)) return true; // p1, q1 and q2 are collinear and q2 lies on p1q1
    if (o3 === 0 && onSegment(p2, p1, q2)) return true; // p2, q2 and p1 are collinear and p1 lies on p2q2
    if (o4 === 0 && onSegment(p2, q1, q2)) return true; // p2, q2 and q1 are collinear and q1 lies on p2q2

    return false;
}

// for collinear points p, q, r, check if point q lies on segment pr
function onSegment(p, q, r) {
    return q.x <= Math.max(p.x, r.x) && q.x >= Math.min(p.x, r.x) && q.y <= Math.max(p.y, r.y) && q.y >= Math.min(p.y, r.y);
}

function sign(num) {
    return num > 0 ? 1 : num < 0 ? -1 : 0;
}

// check if a polygon diagonal intersects any polygon segments
function intersectsPolygon(a, b) {
    let p = a;
    do {
        if (p.i !== a.i && p.next.i !== a.i && p.i !== b.i && p.next.i !== b.i &&
                intersects(p, p.next, a, b)) return true;
        p = p.next;
    } while (p !== a);

    return false;
}

// check if a polygon diagonal is locally inside the polygon
function locallyInside(a, b) {
    return area(a.prev, a, a.next) < 0 ?
        area(a, b, a.next) >= 0 && area(a, a.prev, b) >= 0 :
        area(a, b, a.prev) < 0 || area(a, a.next, b) < 0;
}

// check if the middle point of a polygon diagonal is inside the polygon
function middleInside(a, b) {
    let p = a;
    let inside = false;
    const px = (a.x + b.x) / 2;
    const py = (a.y + b.y) / 2;
    do {
        if (((p.y > py) !== (p.next.y > py)) && p.next.y !== p.y &&
                (px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x))
            inside = !inside;
        p = p.next;
    } while (p !== a);

    return inside;
}

// link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two;
// if one belongs to the outer ring and another to a hole, it merges it into a single ring
function splitPolygon(a, b) {
    const a2 = createNode(a.i, a.x, a.y),
        b2 = createNode(b.i, b.x, b.y),
        an = a.next,
        bp = b.prev;

    a.next = b;
    b.prev = a;

    a2.next = an;
    an.prev = a2;

    b2.next = a2;
    a2.prev = b2;

    bp.next = b2;
    b2.prev = bp;

    return b2;
}

// create a node and optionally link it with previous one (in a circular doubly linked list)
function insertNode(i, x, y, last) {
    const p = createNode(i, x, y);

    if (!last) {
        p.prev = p;
        p.next = p;

    } else {
        p.next = last.next;
        p.prev = last;
        last.next.prev = p;
        last.next = p;
    }
    return p;
}

function removeNode(p) {
    p.next.prev = p.prev;
    p.prev.next = p.next;

    if (p.prevZ) p.prevZ.nextZ = p.nextZ;
    if (p.nextZ) p.nextZ.prevZ = p.prevZ;
}

function createNode(i, x, y) {
    return {
        i, // vertex index in coordinates array
        x, y, // vertex coordinates
        prev: null, // previous and next vertex nodes in a polygon ring
        next: null,
        z: 0, // z-order curve value
        prevZ: null, // previous and next nodes in z-order
        nextZ: null,
        steiner: false // indicates whether this is a steiner point
    };
}

function signedArea(data, start, end, dim) {
    let sum = 0;
    for (let i = start, j = end - dim; i < end; i += dim) {
        sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]);
        j = i;
    }
    return sum;
}

const FRAGMENT_SHADER$3 = `
precision mediump float;
varying vec4 color;
void main() {
  gl_FragColor = color;
}`;
// Vertex shader from https://mattdesl.svbtle.com/drawing-lines-is-hard
// The MIT License (MIT) Copyright (c) 2015 Matt DesLauriers
const VERTEX_SHADER$1 = `
uniform mat4 projectionViewModel;
uniform float aspectRatio;

uniform sampler2D colorTex;
uniform float colorTexRes;
uniform float colorTexEps;
uniform float width;
uniform float useOpacity;
uniform float useColorOpacity;
uniform int miter;

attribute vec3 prevPosition;
attribute vec3 currPosition;
attribute vec3 nextPosition;
attribute float opacity;
attribute float offsetScale;
attribute float colorIndex;

varying vec4 color;

void main() {
  vec2 aspectVec = vec2(aspectRatio, 1.0);
  vec4 prevProjected = projectionViewModel * vec4(prevPosition, 1.0);
  vec4 currProjected = projectionViewModel * vec4(currPosition, 1.0);
  vec4 nextProjected = projectionViewModel * vec4(nextPosition, 1.0);

  // get 2D screen space with W divide and aspect correction
  vec2 prevScreen = prevProjected.xy / prevProjected.w * aspectVec;
  vec2 currScreen = currProjected.xy / currProjected.w * aspectVec;
  vec2 nextScreen = nextProjected.xy / nextProjected.w * aspectVec;

  float len = width;

  // starting point uses (next - current)
  vec2 dir = vec2(0.0);
  if (currScreen == prevScreen) {
    dir = normalize(nextScreen - currScreen);
  }
  // ending point uses (current - previous)
  else if (currScreen == nextScreen) {
    dir = normalize(currScreen - prevScreen);
  }
  // somewhere in middle, needs a join
  else {
    // get directions from (C - B) and (B - A)
    vec2 dirA = normalize((currScreen - prevScreen));
    if (miter == 1) {
      vec2 dirB = normalize((nextScreen - currScreen));
      // now compute the miter join normal and length
      vec2 tangent = normalize(dirA + dirB);
      vec2 perp = vec2(-dirA.y, dirA.x);
      vec2 miter = vec2(-tangent.y, tangent.x);
      len = width / dot(miter, perp);
      dir = tangent;
    } else {
      dir = dirA;
    }
  }

  vec2 normal = vec2(-dir.y, dir.x) * len;
  normal.x /= aspectRatio;
  vec4 offset = vec4(normal * offsetScale, 0.0, 0.0);
  gl_Position = currProjected + offset;

  // Get color from texture
  float colorRowIndex = floor((colorIndex + colorTexEps) / colorTexRes);
  vec2 colorTexIndex = vec2(
    (colorIndex / colorTexRes) - colorRowIndex + colorTexEps,
    colorRowIndex / colorTexRes + colorTexEps
  );

  color = texture2D(colorTex, colorTexIndex);
  color.a = useColorOpacity * color.a + useOpacity * opacity;
}`;
const I = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const FLOAT_BYTES$1 = Float32Array.BYTES_PER_ELEMENT;
const isPositiveNumber$1 = (n) => {
    return n !== undefined && Number.isFinite(n);
};
const isNestedArray = (arr) => {
    return arr.length > 0 && Array.isArray(arr[0]);
};
const { push, splice } = Array.prototype;
const createMesh = (numPointsPerLine, buffer = []) => {
    let numPrevPoints = 0;
    for (const numPoints of numPointsPerLine) {
        for (let i = 0; i < numPoints - 1; i++) {
            const a = numPrevPoints + i * 2; // `2`  because we duplicated all points
            const b = a + 1;
            const c = a + 2;
            const d = a + 3;
            buffer.push(a, b, c, c, b, d);
        }
        // Each line adds an additional start and end point, hence, `numPoints + 2`
        // And again, since all points are duplicated, we have `* 2`
        numPrevPoints += (numPoints + 2) * 2;
    }
    return buffer;
};
function bufferDuplicate(buffer, stride = 1, dupScale = 1) {
    const out = [];
    const component = new Array(stride * 2);
    for (let i = 0, il = buffer.length / stride; i < il; i++) {
        const index = i * stride;
        for (let j = 0; j < stride; j++) {
            const value = buffer[index + j];
            component[j] = value;
            component[j + stride] = value * dupScale;
        }
        push.apply(out, component);
    }
    return out;
}
function bufferCopyElement(buffer, sourceElementIndex, targetIndex, stride) {
    const component = new Array(stride);
    const ai = sourceElementIndex * stride;
    // Copy source element component wise
    for (let i = 0; i < stride; i++) {
        component[i] = buffer[ai + i];
    }
    splice.call(buffer, targetIndex * stride, 0, ...component);
    return buffer;
}
function bufferIncreaseStride(buffer, stride, newStride, undefValue = 0) {
    const out = [];
    const component = new Array(newStride).fill(undefValue);
    for (let i = 0, il = buffer.length / stride; i < il; i++) {
        const index = i * stride;
        for (let j = 0; j < stride; j++) {
            component[j] = buffer[index + j];
        }
        push.apply(out, component);
    }
    return out;
}
const createLine = (regl, { projection = I, model = I, view = I, points = [], colorIndices = [], color = [0.8, 0.5, 0, 1], opacity = null, opacities = [], width = 1, widths = [], miter = true, is2d = false, zPos2d = 0, } = {}) => {
    if (!regl) {
        console.error("Regl instance is undefined.");
        return;
    }
    const pvm = new Float32Array(16);
    let numLines;
    let numPoints;
    let numPointsPerLine;
    let pointsPadded;
    let pointsDup;
    let colorIndicesDup;
    let opacitiesDup;
    let widthsDup;
    let indices;
    let pointBuffer;
    let opacityBuffer;
    let widthBuffer;
    let colorTex;
    let colorTexRes;
    let colorIndexBuffer;
    let attributes;
    let elements;
    let drawLine;
    let dim = is2d ? 2 : 3;
    const useOpacity = () => +(opacities.length === numPoints || opacity !== null);
    const init = () => {
        // @ts-ignore
        pointBuffer = regl.buffer();
        // @ts-ignore
        opacityBuffer = regl.buffer();
        // @ts-ignore
        widthBuffer = regl.buffer();
        // @ts-ignore
        colorIndexBuffer = regl.buffer();
        attributes = {
            prevPosition: {
                // Typing issue in Regl
                buffer: (() => pointBuffer),
                offset: 0,
                stride: FLOAT_BYTES$1 * 3,
            },
            currPosition: {
                // Typing issue in Regl
                buffer: (() => pointBuffer),
                // note that each point is duplicated, hence we need to skip over the first two
                offset: FLOAT_BYTES$1 * 3 * 2,
                stride: FLOAT_BYTES$1 * 3,
            },
            nextPosition: {
                // Typing issue in Regl
                buffer: (() => pointBuffer),
                // note that each point is duplicated, hence we need to skip over the first four
                offset: FLOAT_BYTES$1 * 3 * 4,
                stride: FLOAT_BYTES$1 * 3,
            },
            opacity: {
                // Typing issue in Regl
                buffer: (() => opacityBuffer),
                // note that each point is duplicated, hence we need to skip over the first two
                offset: FLOAT_BYTES$1 * 2,
                stride: FLOAT_BYTES$1,
            },
            offsetScale: {
                // Typing issue in Regl
                buffer: (() => widthBuffer),
                // note that each point is duplicated, hence we need to skip over the first two
                offset: FLOAT_BYTES$1 * 2,
                stride: FLOAT_BYTES$1,
            },
            colorIndex: {
                // Typing issue in Regl
                buffer: (() => colorIndexBuffer),
                // note that each point is duplicated, hence we need to skip over the first two
                offset: FLOAT_BYTES$1 * 2,
                stride: FLOAT_BYTES$1,
            },
        };
        // @ts-ignore
        elements = regl.elements();
        drawLine = regl({
            attributes,
            depth: { enable: !is2d },
            blend: {
                enable: true,
                func: {
                    // biome-ignore lint/style/useNamingConvention: predefined property
                    srcRGB: "src alpha",
                    srcAlpha: "one",
                    // biome-ignore lint/style/useNamingConvention: predefined property
                    dstRGB: "one minus src alpha",
                    dstAlpha: "one minus src alpha",
                },
            },
            uniforms: {
                projectionViewModel: (context, props) => {
                    const projection = context.projection || props.projection;
                    const model = context.model || props.model;
                    const view = context.view || props.view;
                    return multiply(pvm, projection, multiply(pvm, view, model));
                },
                aspectRatio: ({ viewportWidth, viewportHeight }) => viewportWidth / viewportHeight,
                colorTex: () => colorTex,
                colorTexRes: () => colorTexRes,
                colorTexEps: () => 0.5 / colorTexRes,
                pixelRatio: ({ pixelRatio }) => pixelRatio,
                width: ({ pixelRatio, viewportHeight }) => (width / viewportHeight) * pixelRatio,
                useOpacity,
                useColorOpacity: () => Number(!useOpacity()),
                miter: Number(Boolean(miter)),
            },
            elements: () => elements,
            vert: VERTEX_SHADER$1,
            frag: FRAGMENT_SHADER$3,
        });
    };
    const prepare = () => {
        if (numLines === 1 && points.length % dim > 0) {
            console.warn(`The length of points (${numPoints}) does not match the dimensions (${dim}). Incomplete points are ignored.`);
        }
        // Copy all points belonging to complete points
        pointsPadded = points.flat().slice(0, numPoints * dim);
        // Add the missing z point
        if (is2d) {
            pointsPadded = bufferIncreaseStride(pointsPadded, 2, 3, zPos2d);
        }
        if (colorIndices.length !== numPoints) {
            colorIndices = new Array(numPoints).fill(0);
        }
        if (widths.length !== numPoints) {
            widths = new Array(numPoints).fill(1);
        }
        const finalColorIndices = colorIndices.slice();
        const finalOpacities = opacities.length === numPoints
            ? opacities.slice()
            : new Array(numPoints).fill(opacity === null ? 1 : opacity);
        const finalWidths = widths.slice();
        let k = 0;
        for (const n of numPointsPerLine) {
            const lastPointIdx = k + n - 1;
            // For each line, duplicate the first and last point.
            // E.g., [1,2,3] -> [1,1,2,3,3]
            // First, copy the last point to the end
            bufferCopyElement(pointsPadded, lastPointIdx, lastPointIdx, 3);
            // Second, copy the first point to the beginning
            bufferCopyElement(pointsPadded, k, k, 3);
            bufferCopyElement(finalColorIndices, lastPointIdx, lastPointIdx, 1);
            bufferCopyElement(finalColorIndices, k, k, 1);
            bufferCopyElement(finalOpacities, lastPointIdx, lastPointIdx, 1);
            bufferCopyElement(finalOpacities, k, k, 1);
            bufferCopyElement(finalWidths, lastPointIdx, lastPointIdx, 1);
            bufferCopyElement(finalWidths, k, k, 1);
            k += n + 2;
        }
        // duplicate each point for the positive and negative width (see below)
        pointsDup = new Float32Array(bufferDuplicate(pointsPadded, 3));
        // duplicate each color, opacity, and width such that we have a positive
        // and negative width
        colorIndicesDup = bufferDuplicate(finalColorIndices);
        opacitiesDup = bufferDuplicate(finalOpacities);
        widthsDup = bufferDuplicate(finalWidths, 1, -1);
        // create the line mesh, i.e.,: number[] the vertex indices
        indices = createMesh(numPointsPerLine);
        pointBuffer({
            usage: "dynamic",
            type: "float",
            length: pointsDup.length * FLOAT_BYTES$1,
            data: pointsDup,
        });
        opacityBuffer({
            usage: "dynamic",
            type: "float",
            length: opacitiesDup.length * FLOAT_BYTES$1,
            data: opacitiesDup,
        });
        widthBuffer({
            usage: "dynamic",
            type: "float",
            length: widthsDup.length * FLOAT_BYTES$1,
            data: widthsDup,
        });
        colorIndexBuffer({
            usage: "dynamic",
            type: "float",
            length: colorIndicesDup.length * FLOAT_BYTES$1,
            data: colorIndicesDup,
        });
        elements({
            primitive: "triangles",
            usage: "dynamic",
            type: indices.length > 2 ** 16 ? "uint32" : "uint16",
            data: indices,
        });
    };
    const clear = () => {
        destroy();
        init();
    };
    const destroy = () => {
        points = [];
        pointsPadded = [];
        pointsDup = new Float32Array();
        widthsDup = [];
        indices = [];
        pointBuffer.destroy();
        widthBuffer.destroy();
        elements.destroy();
    };
    const draw = ({ projection: newProjection, model: newModel, view: newView, } = {}) => {
        // cache the view-defining matrices
        if (newProjection) {
            projection = newProjection;
        }
        if (newModel) {
            model = newModel;
        }
        if (newView) {
            view = newView;
        }
        // only draw when some points have been specified
        if (points && points.length > 1) {
            drawLine({ projection, model, view });
        }
    };
    const getPerPointProperty = (property, newValues) => {
        const flatValues = newValues.flat(2);
        if (flatValues.length === numPoints) {
            return flatValues;
        }
        if (flatValues.length === numLines) {
            return numPointsPerLine.flatMap((n, i) => Array(n).fill(flatValues[i]));
        }
        return property;
    };
    const getPoints = () => points;
    const setPoints = (newPoints = [], { colorIndices: newColorIndices = colorIndices, opacities: newOpacities = opacities, widths: newWidths = widths, is2d: newIs2d = is2d, } = {}) => {
        points = newPoints;
        is2d = newIs2d;
        dim = is2d ? 2 : 3;
        numLines = isNestedArray(points) ? points.length : 1;
        numPointsPerLine = isNestedArray(points)
            ? points.map((pts) => Math.floor(pts.length / dim))
            : [Math.floor(points.length / dim)];
        numPoints = numPointsPerLine.reduce((n, nPts) => n + nPts, 0);
        colorIndices = getPerPointProperty(colorIndices, newColorIndices);
        opacities = getPerPointProperty(opacities, newOpacities);
        widths = getPerPointProperty(widths, newWidths);
        if (points && numPoints > 1) {
            prepare();
        }
        else {
            clear();
        }
    };
    const createColorTexture = () => {
        const colors = isNestedArray(color) ? color : [color];
        colorTexRes = Math.max(2, Math.ceil(Math.sqrt(colors.length)));
        const rgba = new Uint8Array(colorTexRes ** 2 * 4);
        colors.forEach((color, i) => {
            rgba[i * 4] = Math.min(255, Math.max(0, Math.round(color[0] * 255))); // r
            rgba[i * 4 + 1] = Math.min(255, Math.max(0, Math.round(color[1] * 255))); // g
            rgba[i * 4 + 2] = Math.min(255, Math.max(0, Math.round(color[2] * 255))); // b
            rgba[i * 4 + 3] = Number.isNaN(+color[3])
                ? 255
                : Math.min(255, Math.max(0, Math.round(color[3] * 255))); // a
        });
        colorTex = regl.texture({
            data: rgba,
            shape: [colorTexRes, colorTexRes, 4],
        });
    };
    const setColor = (newColor, newOpacity = opacity) => {
        color = newColor;
        opacity = newOpacity;
        if (colorTex) {
            colorTex.destroy();
        }
        createColorTexture();
    };
    const getStyle = () => ({ color, miter, width });
    const setStyle = ({ color: newColor, opacity: newOpacity, miter: newMiter, width: newWidth, } = {}) => {
        if (newColor) {
            setColor(newColor, newOpacity || opacity);
        }
        if (newMiter) {
            miter = Boolean(newMiter);
        }
        if (isPositiveNumber$1(newWidth)) {
            width = newWidth;
        }
    };
    const getBuffer = () => ({
        points: pointBuffer,
        widths: widthBuffer,
        opacities: opacityBuffer,
        colorIndices: colorIndexBuffer,
    });
    const getData = () => ({
        points: pointsDup,
        widths: widthsDup,
        opacities: opacitiesDup,
        colorIndices: colorIndicesDup,
    });
    // initialize parameters
    init();
    createColorTexture();
    // prepare data if points are already specified
    if (points && points.length > 1) {
        setPoints(points);
    }
    return {
        clear,
        destroy,
        draw,
        getPoints,
        setPoints,
        getData,
        getBuffer,
        getStyle,
        setStyle,
    };
};

/**
 * KDBush - A fast static index for 2D points
 * @license ISC License
 * @copyright Vladimir Agafonkin 2018
 * @version 4.0.2
 * @see https://github.com/mourner/kdbush/
 */
var createKDBushClass = () => {
  const ARRAY_TYPES = [
    Int8Array,
    Uint8Array,
    Uint8ClampedArray,
    Int16Array,
    Uint16Array,
    Int32Array,
    Uint32Array,
    Float32Array,
    Float64Array,
  ];

  /** @typedef {Int8ArrayConstructor | Uint8ArrayConstructor | Uint8ClampedArrayConstructor | Int16ArrayConstructor | Uint16ArrayConstructor | Int32ArrayConstructor | Uint32ArrayConstructor | Float32ArrayConstructor | Float64ArrayConstructor} TypedArrayConstructor */

  const VERSION = 1; // serialized format version
  const HEADER_SIZE = 8;

  class KDBush {
    /**
     * Creates an index from raw `ArrayBuffer` data.
     * @param {ArrayBuffer} data
     */
    static from(data) {
      if (!(data instanceof ArrayBuffer)) {
        throw new Error('Data must be an instance of ArrayBuffer.');
      }
      const [magic, versionAndType] = new Uint8Array(data, 0, 2);
      if (magic !== 0xdb) {
        throw new Error('Data does not appear to be in a KDBush format.');
      }
      const version = versionAndType >> 4;
      if (version !== VERSION) {
        throw new Error(`Got v${version} data when expected v${VERSION}.`);
      }
      const ArrayType = ARRAY_TYPES[versionAndType & 0x0f];
      if (!ArrayType) {
        throw new Error('Unrecognized array type.');
      }
      const [nodeSize] = new Uint16Array(data, 2, 1);
      const [numItems] = new Uint32Array(data, 4, 1);

      return new KDBush(numItems, nodeSize, ArrayType, data);
    }

    /**
     * Creates an index that will hold a given number of items.
     * @param {number} numItems
     * @param {number} [nodeSize=64] Size of the KD-tree node (64 by default).
     * @param {TypedArrayConstructor} [ArrayType=Float64Array] The array type used for coordinates storage (`Float64Array` by default).
     * @param {ArrayBuffer} [data] (For internal use only)
     */
    constructor(numItems, nodeSize = 64, ArrayType = Float64Array, data) {
      if (isNaN(numItems) || numItems < 0)
        throw new Error(`Unexpected numItems value: ${numItems}.`);

      this.numItems = +numItems;
      this.nodeSize = Math.min(Math.max(+nodeSize, 2), 65535);
      this.ArrayType = ArrayType;
      this.IndexArrayType = numItems < 65536 ? Uint16Array : Uint32Array;

      const arrayTypeIndex = ARRAY_TYPES.indexOf(this.ArrayType);
      const coordsByteSize = numItems * 2 * this.ArrayType.BYTES_PER_ELEMENT;
      const idsByteSize = numItems * this.IndexArrayType.BYTES_PER_ELEMENT;
      const padCoords = (8 - (idsByteSize % 8)) % 8;

      if (arrayTypeIndex < 0) {
        throw new Error(`Unexpected typed array class: ${ArrayType}.`);
      }

      if (data && data instanceof ArrayBuffer) {
        // reconstruct an index from a buffer
        this.data = data;
        this.ids = new this.IndexArrayType(this.data, HEADER_SIZE, numItems);
        this.coords = new this.ArrayType(
          this.data,
          HEADER_SIZE + idsByteSize + padCoords,
          numItems * 2
        );
        this._pos = numItems * 2;
        this._finished = true;
      } else {
        // initialize a new index
        this.data = new ArrayBuffer(
          HEADER_SIZE + coordsByteSize + idsByteSize + padCoords
        );
        this.ids = new this.IndexArrayType(this.data, HEADER_SIZE, numItems);
        this.coords = new this.ArrayType(
          this.data,
          HEADER_SIZE + idsByteSize + padCoords,
          numItems * 2
        );
        this._pos = 0;
        this._finished = false;

        // set header
        new Uint8Array(this.data, 0, 2).set([
          0xdb,
          (VERSION << 4) + arrayTypeIndex,
        ]);
        new Uint16Array(this.data, 2, 1)[0] = nodeSize;
        new Uint32Array(this.data, 4, 1)[0] = numItems;
      }
    }

    /**
     * Add a point to the index.
     * @param {number} x
     * @param {number} y
     * @returns {number} An incremental index associated with the added item (starting from `0`).
     */
    add(x, y) {
      const index = this._pos >> 1;
      this.ids[index] = index;
      this.coords[this._pos++] = x;
      this.coords[this._pos++] = y;
      return index;
    }

    /**
     * Perform indexing of the added points.
     */
    finish() {
      const numAdded = this._pos >> 1;
      if (numAdded !== this.numItems) {
        throw new Error(
          `Added ${numAdded} items when expected ${this.numItems}.`
        );
      }
      // kd-sort both arrays for efficient search
      sort(this.ids, this.coords, this.nodeSize, 0, this.numItems - 1, 0);

      this._finished = true;
      return this;
    }

    /**
     * Search the index for items within a given bounding box.
     * @param {number} minX
     * @param {number} minY
     * @param {number} maxX
     * @param {number} maxY
     * @returns {number[]} An array of indices correponding to the found items.
     */
    range(minX, minY, maxX, maxY) {
      if (!this._finished)
        throw new Error('Data not yet indexed - call index.finish().');

      const { ids, coords, nodeSize } = this;
      const stack = [0, ids.length - 1, 0];
      const result = [];

      // recursively search for items in range in the kd-sorted arrays
      while (stack.length) {
        const axis = stack.pop() || 0;
        const right = stack.pop() || 0;
        const left = stack.pop() || 0;

        // if we reached "tree node", search linearly
        if (right - left <= nodeSize) {
          for (let i = left; i <= right; i++) {
            const x = coords[2 * i];
            const y = coords[2 * i + 1];
            if (x >= minX && x <= maxX && y >= minY && y <= maxY)
              result.push(ids[i]);
          }
          continue;
        }

        // otherwise find the middle index
        const m = (left + right) >> 1;

        // include the middle item if it's in range
        const x = coords[2 * m];
        const y = coords[2 * m + 1];
        if (x >= minX && x <= maxX && y >= minY && y <= maxY)
          result.push(ids[m]);

        // queue search in halves that intersect the query
        if (axis === 0 ? minX <= x : minY <= y) {
          stack.push(left);
          stack.push(m - 1);
          stack.push(1 - axis);
        }
        if (axis === 0 ? maxX >= x : maxY >= y) {
          stack.push(m + 1);
          stack.push(right);
          stack.push(1 - axis);
        }
      }

      return result;
    }

    /**
     * Search the index for items within a given radius.
     * @param {number} qx
     * @param {number} qy
     * @param {number} r Query radius.
     * @returns {number[]} An array of indices correponding to the found items.
     */
    within(qx, qy, r) {
      if (!this._finished)
        throw new Error('Data not yet indexed - call index.finish().');

      const { ids, coords, nodeSize } = this;
      const stack = [0, ids.length - 1, 0];
      const result = [];
      const r2 = r * r;

      // recursively search for items within radius in the kd-sorted arrays
      while (stack.length) {
        const axis = stack.pop() || 0;
        const right = stack.pop() || 0;
        const left = stack.pop() || 0;

        // if we reached "tree node", search linearly
        if (right - left <= nodeSize) {
          for (let i = left; i <= right; i++) {
            if (sqDist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2)
              result.push(ids[i]);
          }
          continue;
        }

        // otherwise find the middle index
        const m = (left + right) >> 1;

        // include the middle item if it's in range
        const x = coords[2 * m];
        const y = coords[2 * m + 1];
        if (sqDist(x, y, qx, qy) <= r2) result.push(ids[m]);

        // queue search in halves that intersect the query
        if (axis === 0 ? qx - r <= x : qy - r <= y) {
          stack.push(left);
          stack.push(m - 1);
          stack.push(1 - axis);
        }
        if (axis === 0 ? qx + r >= x : qy + r >= y) {
          stack.push(m + 1);
          stack.push(right);
          stack.push(1 - axis);
        }
      }

      return result;
    }
  }

  /**
   * @param {Uint16Array | Uint32Array} ids
   * @param {InstanceType<TypedArrayConstructor>} coords
   * @param {number} nodeSize
   * @param {number} left
   * @param {number} right
   * @param {number} axis
   */
  function sort(ids, coords, nodeSize, left, right, axis) {
    if (right - left <= nodeSize) return;

    const m = (left + right) >> 1; // middle index

    // sort ids and coords around the middle index so that the halves lie
    // either left/right or top/bottom correspondingly (taking turns)
    select(ids, coords, m, left, right, axis);

    // recursively kd-sort first half and second half on the opposite axis
    sort(ids, coords, nodeSize, left, m - 1, 1 - axis);
    sort(ids, coords, nodeSize, m + 1, right, 1 - axis);
  }

  /**
   * Custom Floyd-Rivest selection algorithm: sort ids and coords so that
   * [left..k-1] items are smaller than k-th item (on either x or y axis)
   * @param {Uint16Array | Uint32Array} ids
   * @param {InstanceType<TypedArrayConstructor>} coords
   * @param {number} k
   * @param {number} left
   * @param {number} right
   * @param {number} axis
   */
  function select(ids, coords, k, left, right, axis) {
    while (right > left) {
      if (right - left > 600) {
        const n = right - left + 1;
        const m = k - left + 1;
        const z = Math.log(n);
        const s = 0.5 * Math.exp((2 * z) / 3);
        const sd =
          0.5 * Math.sqrt((z * s * (n - s)) / n) * (m - n / 2 < 0 ? -1 : 1);
        const newLeft = Math.max(left, Math.floor(k - (m * s) / n + sd));
        const newRight = Math.min(
          right,
          Math.floor(k + ((n - m) * s) / n + sd)
        );
        select(ids, coords, k, newLeft, newRight, axis);
      }

      const t = coords[2 * k + axis];
      let i = left;
      let j = right;

      swapItem(ids, coords, left, k);
      if (coords[2 * right + axis] > t) swapItem(ids, coords, left, right);

      while (i < j) {
        swapItem(ids, coords, i, j);
        i++;
        j--;
        while (coords[2 * i + axis] < t) i++;
        while (coords[2 * j + axis] > t) j--;
      }

      if (coords[2 * left + axis] === t) swapItem(ids, coords, left, j);
      else {
        j++;
        swapItem(ids, coords, j, right);
      }

      if (j <= k) left = j + 1;
      if (k <= j) right = j - 1;
    }
  }

  /**
   * @param {Uint16Array | Uint32Array} ids
   * @param {InstanceType<TypedArrayConstructor>} coords
   * @param {number} i
   * @param {number} j
   */
  function swapItem(ids, coords, i, j) {
    swap(ids, i, j);
    swap(coords, 2 * i, 2 * j);
    swap(coords, 2 * i + 1, 2 * j + 1);
  }

  /**
   * @param {InstanceType<TypedArrayConstructor>} arr
   * @param {number} i
   * @param {number} j
   */
  function swap(arr, i, j) {
    const tmp = arr[i];
    arr[i] = arr[j];
    arr[j] = tmp;
  }

  /**
   * @param {number} ax
   * @param {number} ay
   * @param {number} bx
   * @param {number} by
   */
  function sqDist(ax, ay, bx, by) {
    const dx = ax - bx;
    const dy = ay - by;
    return dx * dx + dy * dy;
  }

  return KDBush;
};

var workerFn = () => {
  addEventListener('message', (event) => {
    const points = event.data.points;

    if (points.length === 0) {
      self.postMessage({ error: new Error('Invalid point data') });
    }

    // biome-ignore lint/correctness/noUndeclaredVariables: KDBush is made available during compilation
    const index = new KDBush(points.length, event.data.nodeSize);

    for (const [x, y] of points) {
      index.add(x, y);
    }

    index.finish();

    postMessage(index.data, [index.data]);
  });
};

// biome-ignore lint/style/useNamingConvention: KDBush is a library name

// biome-ignore lint/style/useNamingConvention: KDBush is a library name
const KDBush$1 = createKDBushClass();
const WORKER_THRESHOLD = 1000000;

const createWorker = (fn) => {
  const kdbushStr = createKDBushClass.toString();
  const fnStr = fn.toString();
  const workerStr =
    // biome-ignore lint/style/useTemplate: Prefer one assignment per line
    `const createKDBushClass = ${kdbushStr};` +
    'KDBush = createKDBushClass();' +
    `const createWorker = ${fnStr};` +
    'createWorker();';

  const blob = new Blob([workerStr], { type: 'text/javascript' });
  const workerUrl = URL.createObjectURL(blob);
  const worker = new Worker(workerUrl, { name: 'KDBush' });

  // Clean up URL
  URL.revokeObjectURL(workerUrl);

  return worker;
};

/**
 * Create KDBush from an either point data or an existing spatial index
 * @param {import('./types').Points | ArrayBuffer} pointsOrIndex - Points or KDBush index
 * @param {Partial<import('./types').CreateKDBushOptions>} options - Options for configuring the index and its creation
 * @return {Promise<KDBush>} KDBush instance
 */
const createKdbush = (
  pointsOrIndex,
  options = { nodeSize: 16, useWorker: undefined },
) =>
  new Promise((resolve, reject) => {
    if (pointsOrIndex instanceof ArrayBuffer) {
      resolve(KDBush$1.from(pointsOrIndex));
    } else if (
      (pointsOrIndex.length < WORKER_THRESHOLD ||
        options.useWorker === false) &&
      options.useWorker !== true
    ) {
      const index = new KDBush$1(pointsOrIndex.length, options.nodeSize);
      for (const pointOrIndex of pointsOrIndex) {
        index.add(pointOrIndex[0], pointOrIndex[1]);
      }
      index.finish();
      resolve(index);
    } else {
      const worker = createWorker(workerFn);

      worker.onmessage = (e) => {
        if (e.data.error) {
          reject(e.data.error);
        } else {
          resolve(KDBush$1.from(e.data));
        }
        worker.terminate();
      };

      worker.postMessage({ points: pointsOrIndex, nodeSize: options.nodeSize });
    }
  });

const DEFAULT_LASSO_START_INITIATOR_SHOW = true;
const DEFAULT_LASSO_MIN_DELAY$1 = 8;
const DEFAULT_LASSO_MIN_DIST$1 = 2;
const DEFAULT_LASSO_TYPE$1 = 'freeform';
const DEFAULT_BRUSH_SIZE = 24;
const LASSO_SHOW_START_INITIATOR_TIME = 2500;
const LASSO_HIDE_START_INITIATOR_TIME = 250;

const AUTO = 'auto';

const COLOR_NORMAL_IDX = 0;
const COLOR_ACTIVE_IDX = 1;
const COLOR_HOVER_IDX = 2;
const COLOR_BG_IDX = 3;
const COLOR_NUM_STATES = 4;
const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
const GL_EXTENSIONS = [
  'OES_texture_float',
  'OES_element_index_uint',
  'WEBGL_color_buffer_float',
  'EXT_float_blend',
];
const CLEAR_OPTIONS = {
  color: [0, 0, 0, 0], // Transparent background color
  depth: 1,
};

const MOUSE_MODE_PANZOOM = 'panZoom';
const MOUSE_MODE_LASSO = 'lasso';
const MOUSE_MODE_ROTATE = 'rotate';
const MOUSE_MODES = [
  MOUSE_MODE_PANZOOM,
  MOUSE_MODE_LASSO,
  MOUSE_MODE_ROTATE,
];
const DEFAULT_MOUSE_MODE = MOUSE_MODE_PANZOOM;

// Easing
const EASING_FNS = {
  cubicIn,
  cubicInOut,
  cubicOut,
  linear,
  quadIn,
  quadInOut,
  quadOut,
};
const DEFAULT_EASING = cubicInOut;

const CONTINUOUS = 'continuous';
const CATEGORICAL = 'categorical';
const VALUE_ZW_DATA_TYPES = [CONTINUOUS, CATEGORICAL];

// Default lasso
const LASSO_CLEAR_ON_DESELECT = 'deselect';
const LASSO_CLEAR_ON_END = 'lassoEnd';
const LASSO_CLEAR_EVENTS = [LASSO_CLEAR_ON_DESELECT, LASSO_CLEAR_ON_END];
const LASSO_BRUSH_MIN_MIN_DIST = 3;
const DEFAULT_LASSO_COLOR = [0, 0.666666667, 1, 1];
const DEFAULT_LASSO_LINE_WIDTH = 2;
const DEFAULT_LASSO_INITIATOR = false;
const DEFAULT_LASSO_MIN_DELAY = 10;
const DEFAULT_LASSO_MIN_DIST = 3;
const DEFAULT_LASSO_CLEAR_EVENT = LASSO_CLEAR_ON_END;
const DEFAULT_LASSO_ON_LONG_PRESS = false;
const DEFAULT_LASSO_LONG_PRESS_TIME = 750;
const DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME = 500;
const DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY = 100;
const DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME = 250;
const DEFAULT_LASSO_BRUSH_SIZE = 24;

// Key mapping
const KEY_ACTION_LASSO = 'lasso';
const KEY_ACTION_ROTATE = 'rotate';
const KEY_ACTION_MERGE = 'merge';
const KEY_ACTION_REMOVE = 'remove';
const KEY_ACTIONS = [
  KEY_ACTION_LASSO,
  KEY_ACTION_ROTATE,
  KEY_ACTION_MERGE,
  KEY_ACTION_REMOVE,
];
const KEY_ALT = 'alt';
const KEY_CMD = 'cmd';
const KEY_CTRL = 'ctrl';
const KEY_META = 'meta';
const KEY_SHIFT = 'shift';
const KEYS = [KEY_ALT, KEY_CMD, KEY_CTRL, KEY_META, KEY_SHIFT];
const DEFAULT_ACTION_KEY_MAP = {
  [KEY_ACTION_REMOVE]: KEY_ALT,
  [KEY_ACTION_ROTATE]: KEY_ALT,
  [KEY_ACTION_LASSO]: KEY_SHIFT,
  [KEY_ACTION_MERGE]: KEY_CMD,
};

// Default attribute
const DEFAULT_DATA_ASPECT_RATIO = 1;
const DEFAULT_WIDTH = AUTO;
const DEFAULT_HEIGHT = AUTO;
const DEFAULT_GAMMA = 1;

// Default styles
const MIN_POINT_SIZE = 1;
const DEFAULT_POINT_SCALE_MODE = 'asinh';
const DEFAULT_POINT_SIZE = 6;
const DEFAULT_POINT_SIZE_SELECTED = 2;
const DEFAULT_POINT_OUTLINE_WIDTH = 2;
const DEFAULT_SIZE_BY = null;
const DEFAULT_POINT_CONNECTION_SIZE = 2;
const DEFAULT_POINT_CONNECTION_SIZE_ACTIVE = 2;
const DEFAULT_POINT_CONNECTION_SIZE_BY = null;
const DEFAULT_POINT_CONNECTION_OPACITY = null;
const DEFAULT_POINT_CONNECTION_OPACITY_BY = null;
const DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE = 0.66;
const DEFAULT_OPACITY = 1;
const DEFAULT_OPACITY_BY = null;
const DEFAULT_OPACITY_BY_DENSITY_FILL = 0.15;
const DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME = 25;
const DEFAULT_OPACITY_INACTIVE_MAX = 1;
const DEFAULT_OPACITY_INACTIVE_SCALE = 1;
const DEFAULT_COLOR_BY = null;
const DEFAULT_COLOR_NORMAL = [0.66, 0.66, 0.66, DEFAULT_OPACITY];
const DEFAULT_COLOR_ACTIVE = [0, 0.55, 1, 1];
const DEFAULT_COLOR_HOVER = [1, 1, 1, 1];
const DEFAULT_COLOR_BG = [0, 0, 0, 1];
const DEFAULT_POINT_CONNECTION_COLOR_BY = null;
const DEFAULT_POINT_CONNECTION_COLOR_NORMAL = [0.66, 0.66, 0.66, 0.2];
const DEFAULT_POINT_CONNECTION_COLOR_ACTIVE = [0, 0.55, 1, 1];
const DEFAULT_POINT_CONNECTION_COLOR_HOVER = [1, 1, 1, 1];

// Annotations
const DEFAULT_ANNOTATION_LINE_COLOR = [1, 1, 1, 0.5];
const DEFAULT_ANNOTATION_LINE_WIDTH = 1;
const DEFAULT_ANNOTATION_HVLINE_LIMIT = 1000;

// Default view
const DEFAULT_TARGET = [0, 0];
const DEFAULT_DISTANCE = 1;
const DEFAULT_ROTATION = 0;
// biome-ignore format: the array should not be formatted
const DEFAULT_VIEW = new Float32Array([
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, 1, 0,
  0, 0, 0, 1,
]);

// Error codes
const IMAGE_LOAD_ERROR = 'IMAGE_LOAD_ERROR';

// Default misc
const DEFAULT_BACKGROUND_IMAGE = null;
const DEFAULT_SHOW_RETICLE = false;
const DEFAULT_RETICLE_COLOR = [1, 1, 1, 0.5];
const DEFAULT_DESELECT_ON_DBL_CLICK = true;
const DEFAULT_DESELECT_ON_ESCAPE = true;
const DEFAULT_SHOW_POINT_CONNECTIONS = false;
const DEFAULT_POINT_CONNECTION_MAX_INT_POINTS_PER_SEGMENT = 100;
const DEFAULT_POINT_CONNECTION_INT_POINTS_TOLERANCE = 1 / 500;
const DEFAULT_POINT_SIZE_MOUSE_DETECTION = 'auto';
const DEFAULT_PERFORMANCE_MODE = false;
const SINGLE_CLICK_DELAY = 200;
const LONG_CLICK_TIME = 500;
const Z_NAMES = new Set(['z', 'valueZ', 'valueA', 'value1', 'category']);
const W_NAMES = new Set(['w', 'valueW', 'valueB', 'value2', 'value']);
const DEFAULT_IMAGE_LOAD_TIMEOUT = 15000;
const DEFAULT_SPATIAL_INDEX_USE_WORKER = undefined;
const DEFAULT_CAMERA_IS_FIXED = false;
const DEFAULT_ANTI_ALIASING = 0.5;
const DEFAULT_PIXEL_ALIGNED = false;
const DEFAULT_LASSO_TYPE = 'lasso';
const SKIP_DEPRECATION_VALUE_TRANSLATION = Symbol(
  'SKIP_DEPRECATION_VALUE_TRANSLATION',
);

// Error messages
const ERROR_POINTS_NOT_DRAWN = 'Points have not been drawn';
const ERROR_INSTANCE_IS_DESTROYED = 'The instance was already destroyed';
const ERROR_IS_DRAWING =
  'Ignoring draw call as the previous draw call has not yet finished. To avoid this warning `await` the draw call.';

const getInTime = (p, time, extraTime) => (1 - p) * time + extraTime;

const getMainInAnimation = (t, d) =>
  `${t}ms ease-out mainIn ${d}ms 1 normal forwards`;

const getEffectInAnimation = (t, d) =>
  `${t}ms ease-out effectIn ${d}ms 1 normal forwards`;

const getCircleLeftInAnimation = (t, d) =>
  `${t}ms linear leftSpinIn ${d}ms 1 normal forwards`;

const getCircleRightInAnimation = (t, d) =>
  `${t}ms linear rightSpinIn ${d}ms 1 normal forwards`;

const getCircleInAnimation = (t, d) =>
  `${t}ms linear circleIn ${d}ms 1 normal forwards`;

const getMainIn = (mainEffectPercent, currentColor, targetColor) => `
  @keyframes mainIn {
    0% {
      color: ${currentColor};
      opacity: 0;
    }
    0%, ${mainEffectPercent}% {
      color: ${currentColor};
      opacity: 1;
    }
    100% {
      color: ${targetColor};
      opacity: 0.8;
    }
  }
`;

const getEffectIn = (mainEffectPercent, afterEffectPercent, opacity, scale) => `
  @keyframes effectIn {
    0%, ${mainEffectPercent}% {
      opacity: ${opacity};
      transform: scale(${scale});
    }
    ${afterEffectPercent}% {
      opacity: 0.66;
      transform: scale(1.5);
    }
    99% {
      opacity: 0;
      transform: scale(2);
    }
    100% {
      opacity: 0;
      transform: scale(0);
    }
  }
`;

const getCircleIn = (halfMainEffectPercent, clipPath, opacity) => `
  @keyframes circleIn {
    0% {
      clip-path: ${clipPath};
      opacity: ${opacity};
    }
    ${halfMainEffectPercent}% {
      clip-path: ${clipPath};
      opacity: 1;
    }
    ${halfMainEffectPercent + 0.01}%, 100% {
      clip-path: inset(0);
      opacity: 1;
    }
  }
`;

const getCircleLeftIn = (mainEffectPercent, angle) => `
  @keyframes leftSpinIn {
    0% {
      transform: rotate(${angle}deg);
    }
    ${mainEffectPercent}%, 100% {
      transform: rotate(360deg);
    }
  }
`;

const getCircleRightIn = (halfMainEffectPercent, angle) => `
  @keyframes rightSpinIn {
    0% {
      transform: rotate(${angle}deg);
    }
    ${halfMainEffectPercent}%, 100% {
      transform: rotate(180deg);
    }
  }
`;

const createLongPressInAnimations = ({
  time = DEFAULT_LASSO_LONG_PRESS_TIME,
  extraTime = DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME,
  delay = DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY,
  currentColor,
  targetColor,
  effectOpacity,
  effectScale,
  circleLeftRotation,
  circleRightRotation,
  circleClipPath,
  circleOpacity,
}) => {
  const p = circleLeftRotation / 360;
  const actualTime = getInTime(p, time, extraTime);
  const longPressPercent = Math.round((((1 - p) * time) / actualTime) * 100);
  const halfLongPressPercent = Math.round(longPressPercent / 2);
  const afterEffectPercent = longPressPercent + (100 - longPressPercent) / 4;

  return {
    rules: {
      main: getMainIn(longPressPercent, currentColor, targetColor),
      effect: getEffectIn(
        longPressPercent,
        afterEffectPercent,
        effectOpacity,
        effectScale,
      ),
      circleRight: getCircleRightIn(halfLongPressPercent, circleRightRotation),
      circleLeft: getCircleLeftIn(longPressPercent, circleLeftRotation),
      circle: getCircleIn(halfLongPressPercent, circleClipPath, circleOpacity),
    },
    names: {
      main: getMainInAnimation(actualTime, delay),
      effect: getEffectInAnimation(actualTime, delay),
      circleLeft: getCircleLeftInAnimation(actualTime, delay),
      circleRight: getCircleRightInAnimation(actualTime, delay),
      circle: getCircleInAnimation(actualTime, delay),
    },
  };
};

const getMainOutAnimation = (t) => `${t}ms linear mainOut 0s 1 normal forwards`;

const getEffectOutAnimation = (t) =>
  `${t}ms linear effectOut 0s 1 normal forwards`;

const getCircleLeftOutAnimation = (t) =>
  `${t}ms linear leftSpinOut 0s 1 normal forwards`;

const getCircleRightOutAnimation = (t) =>
  `${t}ms linear rightSpinOut 0s 1 normal forwards`;

const getCircleOutAnimation = (t) =>
  `${t}ms linear circleOut 0s 1 normal forwards`;

const getMainOut = (currentColor, targetColor) => `
  @keyframes mainOut {
    0% {
      color: ${currentColor};
    }
    100% {
      color: ${targetColor};
    }
  }
`;

const getEffectOut = (opacity, scale) => `
  @keyframes effectOut {
    0% {
      opacity: ${opacity};
      transform: scale(${scale});
    }
    99% {
      opacity: 0;
      transform: scale(${scale + 0.5});
    }
    100% {
      opacity: 0;
      transform: scale(0);
    }
  }
`;

const getCircleRightOut = (halfEffectPercent, angle) => `
  @keyframes rightSpinOut {
    0%, ${halfEffectPercent}% {
      transform: rotate(${angle}deg);
    }
    100% {
      transform: rotate(0deg);
    }
`;

const getCircleLeftOut = (angle) => `
  @keyframes leftSpinOut {
    0% {
      transform: rotate(${angle}deg);
    }
    100% {
      transform: rotate(0deg);
    }
  }
`;

const getCircleOut = (halfEffectPercent, clipPath, opacity) => `
  @keyframes circleOut {
    0%, ${halfEffectPercent}% {
      clip-path: ${clipPath};
      opacity: ${opacity};
    }
    ${halfEffectPercent + 0.01}% {
      clip-path: inset(0 0 0 50%);
      opacity: ${opacity};
    }
    100% {
      clip-path: inset(0 0 0 50%);
      opacity: 0;
    }
  }
`;

const createLongPressOutAnimations = ({
  time = DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME,
  currentColor,
  targetColor,
  effectOpacity,
  effectScale,
  circleLeftRotation,
  circleRightRotation,
  circleClipPath,
  circleOpacity,
}) => {
  const p = circleLeftRotation / 360;
  const actualTime = p * time;
  const rotatedPercent = Math.min(100, p * 100);
  const halfPercent =
    rotatedPercent > 50 ? Math.round((1 - 50 / rotatedPercent) * 100) : 0;

  return {
    rules: {
      main: getMainOut(currentColor, targetColor),
      effect: getEffectOut(effectOpacity, effectScale),
      circleRight: getCircleRightOut(halfPercent, circleRightRotation),
      circleLeft: getCircleLeftOut(circleLeftRotation),
      circle: getCircleOut(halfPercent, circleClipPath, circleOpacity),
    },
    names: {
      main: getMainOutAnimation(actualTime),
      effect: getEffectOutAnimation(actualTime),
      circleRight: getCircleLeftOutAnimation(actualTime),
      circleLeft: getCircleRightOutAnimation(actualTime),
      circle: getCircleOutAnimation(actualTime),
    },
  };
};

const createLongPressElements = () => {
  const longPress = document.createElement('div');
  const longPressId =
    Math.random().toString(36).substring(2, 5) +
    Math.random().toString(36).substring(2, 5);
  longPress.id = `lasso-long-press-${longPressId}`;
  longPress.style.position = 'fixed';
  longPress.style.width = '1.25rem';
  longPress.style.height = '1.25rem';
  longPress.style.pointerEvents = 'none';
  longPress.style.transform = 'translate(-50%,-50%)';

  const longPressCircle = document.createElement('div');
  longPressCircle.style.position = 'absolute';
  longPressCircle.style.top = 0;
  longPressCircle.style.left = 0;
  longPressCircle.style.width = '1.25rem';
  longPressCircle.style.height = '1.25rem';
  longPressCircle.style.clipPath = 'inset(0px 0px 0px 50%)';
  longPressCircle.style.opacity = 0;
  longPress.appendChild(longPressCircle);

  const longPressCircleLeft = document.createElement('div');
  longPressCircleLeft.style.position = 'absolute';
  longPressCircleLeft.style.top = 0;
  longPressCircleLeft.style.left = 0;
  longPressCircleLeft.style.width = '0.8rem';
  longPressCircleLeft.style.height = '0.8rem';
  longPressCircleLeft.style.border = '0.2rem solid currentcolor';
  longPressCircleLeft.style.borderRadius = '0.8rem';
  longPressCircleLeft.style.clipPath = 'inset(0px 50% 0px 0px)';
  longPressCircleLeft.style.transform = 'rotate(0deg)';
  longPressCircle.appendChild(longPressCircleLeft);

  const longPressCircleRight = document.createElement('div');
  longPressCircleRight.style.position = 'absolute';
  longPressCircleRight.style.top = 0;
  longPressCircleRight.style.left = 0;
  longPressCircleRight.style.width = '0.8rem';
  longPressCircleRight.style.height = '0.8rem';
  longPressCircleRight.style.border = '0.2rem solid currentcolor';
  longPressCircleRight.style.borderRadius = '0.8rem';
  longPressCircleRight.style.clipPath = 'inset(0px 50% 0px 0px)';
  longPressCircleRight.style.transform = 'rotate(0deg)';
  longPressCircle.appendChild(longPressCircleRight);

  const longPressEffect = document.createElement('div');
  longPressEffect.style.position = 'absolute';
  longPressEffect.style.top = 0;
  longPressEffect.style.left = 0;
  longPressEffect.style.width = '1.25rem';
  longPressEffect.style.height = '1.25rem';
  longPressEffect.style.borderRadius = '1.25rem';
  longPressEffect.style.background = 'currentcolor';
  longPressEffect.style.transform = 'scale(0)';
  longPressEffect.style.opacity = 0;
  longPress.appendChild(longPressEffect);

  return {
    longPress,
    longPressCircle,
    longPressCircleLeft,
    longPressCircleRight,
    longPressEffect,
  };
};

/**
 * Calculates exponential moving average of 2D points
 * @param {[number, number][]} values - Array of numbers to average
 * @param {number} halfLife - Number of steps after which weight becomes half
 * @param {number} windowSize - Maximum number of previous values to consider
 * @returns {number} The exponential moving average
 */
const exponentialMovingAverage = (values, halfLife, windowSize) => {
  if (values.length === 0) {
    return 0;
  }

  if (values.length === 1) {
    return values[0];
  }

  // Calculate decay factor from `halfLife` such that weight = 0.5 when the
  // step is `halfLife`
  const decayBase = 2 ** (-1 / halfLife);

  // Limit to window size
  const startIdx = Math.max(0, values.length - windowSize);
  const relevantValues = values.slice(startIdx);

  let weightedSumX = 0;
  let weightedSumY = 0;
  let weightSum = 0;

  // Calculate weighted sum starting from most recent value
  for (let i = relevantValues.length - 1; i >= 0; i--) {
    const steps = relevantValues.length - 1 - i;
    const weight = decayBase ** steps;

    weightedSumX += relevantValues[i][0] * weight;
    weightedSumY += relevantValues[i][1] * weight;
    weightSum += weight;
  }

  return [weightedSumX / weightSum, weightedSumY / weightSum];
};

const ifNotNull = (v, alternative = null) => (v === null ? alternative : v);

let cachedLassoStylesheets;

const getLassoStylesheets = () => {
  if (!cachedLassoStylesheets) {
    const lassoStyleEl = document.createElement('style');
    document.head.appendChild(lassoStyleEl);
    cachedLassoStylesheets = lassoStyleEl.sheet;
  }
  return cachedLassoStylesheets;
};

const addRule = (rule) => {
  const lassoStylesheets = getLassoStylesheets();
  const currentNumRules = lassoStylesheets.rules.length;
  lassoStylesheets.insertRule(rule, currentNumRules);
  return currentNumRules;
};

const removeRule = (index) => {
  getLassoStylesheets().deleteRule(index);
};

const inAnimation = `${LASSO_SHOW_START_INITIATOR_TIME}ms ease scaleInFadeOut 0s 1 normal backwards`;

const createInAnimationRule = (opacity, scale, rotate) => `
@keyframes scaleInFadeOut {
  0% {
    opacity: ${opacity};
    transform: translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg);
  }
  10% {
    opacity: 1;
    transform: translate(-50%,-50%) scale(1) rotate(${rotate + 20}deg);
  }
  100% {
    opacity: 0;
    transform: translate(-50%,-50%) scale(0.9) rotate(${rotate + 60}deg);
  }
}
`;
let inAnimationRuleIndex = null;

const outAnimation = `${LASSO_HIDE_START_INITIATOR_TIME}ms ease fadeScaleOut 0s 1 normal backwards`;

const createOutAnimationRule = (opacity, scale, rotate) => `
@keyframes fadeScaleOut {
  0% {
    opacity: ${opacity};
    transform: translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg);
  }
  100% {
    opacity: 0;
    transform: translate(-50%,-50%) scale(0) rotate(${rotate}deg);
  }
}
`;
let outAnimationRuleIndex = null;

const createLasso = (
  element,
  {
    onDraw: initialOnDraw = identity,
    onStart: initialOnStart = identity,
    onEnd: initialOnEnd = identity,
    enableInitiator:
      initialenableInitiator = DEFAULT_LASSO_START_INITIATOR_SHOW,
    initiatorParentElement: initialInitiatorParentElement = document.body,
    longPressIndicatorParentElement:
      initialLongPressIndicatorParentElement = document.body,
    minDelay: initialMinDelay = DEFAULT_LASSO_MIN_DELAY$1,
    minDist: initialMinDist = DEFAULT_LASSO_MIN_DIST$1,
    pointNorm: initialPointNorm = identity,
    type: initialType = DEFAULT_LASSO_TYPE$1,
    brushSize: initialBrushSize = DEFAULT_BRUSH_SIZE,
  } = {},
) => {
  let enableInitiator = initialenableInitiator;
  let initiatorParentElement = initialInitiatorParentElement;
  let longPressIndicatorParentElement = initialLongPressIndicatorParentElement;

  let onDraw = initialOnDraw;
  let onStart = initialOnStart;
  let onEnd = initialOnEnd;

  let minDelay = initialMinDelay;
  let minDist = initialMinDist;

  let pointNorm = initialPointNorm;

  let type = initialType;
  let brushSize = initialBrushSize;

  const initiator = document.createElement('div');
  const initiatorId =
    Math.random().toString(36).substring(2, 5) +
    Math.random().toString(36).substring(2, 5);
  initiator.id = `lasso-initiator-${initiatorId}`;
  initiator.style.position = 'fixed';
  initiator.style.display = 'flex';
  initiator.style.justifyContent = 'center';
  initiator.style.alignItems = 'center';
  initiator.style.zIndex = 99;
  initiator.style.width = '4rem';
  initiator.style.height = '4rem';
  initiator.style.borderRadius = '4rem';
  initiator.style.opacity = 0.5;
  initiator.style.transform = 'translate(-50%,-50%) scale(0) rotate(0deg)';

  const {
    longPress,
    longPressCircle,
    longPressCircleLeft,
    longPressCircleRight,
    longPressEffect,
  } = createLongPressElements();

  let isMouseDown = false;
  let isLasso = false;
  let lassoPos = [];
  let lassoPosFlat = [];
  let lassoBrushCenterPos = [];
  let lassoBrushNormals = [];
  let prevMousePos;
  let longPressIsStarting = false;

  let longPressMainInAnimationRuleIndex = null;
  let longPressEffectInAnimationRuleIndex = null;
  let longPressCircleLeftInAnimationRuleIndex = null;
  let longPressCircleRightInAnimationRuleIndex = null;
  let longPressCircleInAnimationRuleIndex = null;
  let longPressMainOutAnimationRuleIndex = null;
  let longPressEffectOutAnimationRuleIndex = null;
  let longPressCircleLeftOutAnimationRuleIndex = null;
  let longPressCircleRightOutAnimationRuleIndex = null;
  let longPressCircleOutAnimationRuleIndex = null;

  const mouseUpHandler = () => {
    isMouseDown = false;
  };

  const getMousePosition = (event) => {
    const { left, top } = element.getBoundingClientRect();

    return [event.clientX - left, event.clientY - top];
  };

  window.addEventListener('mouseup', mouseUpHandler);

  const resetInitiatorStyle = () => {
    initiator.style.opacity = 0.5;
    initiator.style.transform = 'translate(-50%,-50%) scale(0) rotate(0deg)';
  };

  const getCurrentTransformStyle = (node, hasRotated) => {
    const computedStyle = getComputedStyle(node);
    const opacity = +computedStyle.opacity;
    // The css rule `transform: translate(-1, -1) scale(0.5);` is represented as
    // `matrix(0.5, 0, 0, 0.5, -1, -1)`
    const m = computedStyle.transform.match(/([0-9.-]+)+/g);

    const a = +m[0];
    const b = +m[1];

    const scale = Math.sqrt(a * a + b * b);
    let rotate = Math.atan2(b, a) * (180 / Math.PI);

    rotate = hasRotated && rotate <= 0 ? 360 + rotate : rotate;

    return { opacity, scale, rotate };
  };

  const showInitiator = (event) => {
    if (!enableInitiator || isMouseDown) {
      return;
    }

    const x = event.clientX;
    const y = event.clientY;
    initiator.style.top = `${y}px`;
    initiator.style.left = `${x}px`;

    const style = getCurrentTransformStyle(initiator);
    const opacity = style.opacity;
    const scale = style.scale;
    const rotate = style.rotate;
    initiator.style.opacity = opacity;
    initiator.style.transform = `translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg)`;

    initiator.style.animation = 'none';

    // See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Tips
    // why we need to wait for two animation frames
    nextAnimationFrame().then(() => {
      if (inAnimationRuleIndex !== null) {
        removeRule(inAnimationRuleIndex);
      }

      inAnimationRuleIndex = addRule(
        createInAnimationRule(opacity, scale, rotate),
      );

      initiator.style.animation = inAnimation;

      nextAnimationFrame().then(() => {
        resetInitiatorStyle();
      });
    });
  };

  const hideInitiator = () => {
    const { opacity, scale, rotate } = getCurrentTransformStyle(initiator);
    initiator.style.opacity = opacity;
    initiator.style.transform = `translate(-50%,-50%) scale(${scale}) rotate(${rotate}deg)`;

    initiator.style.animation = 'none';

    nextAnimationFrame(2).then(() => {
      if (outAnimationRuleIndex !== null) {
        removeRule(outAnimationRuleIndex);
      }

      outAnimationRuleIndex = addRule(
        createOutAnimationRule(opacity, scale, rotate),
      );

      initiator.style.animation = outAnimation;

      nextAnimationFrame().then(() => {
        resetInitiatorStyle();
      });
    });
  };

  const showLongPressIndicator = (
    x,
    y,
    {
      time = DEFAULT_LASSO_LONG_PRESS_TIME,
      extraTime = DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME,
      delay = DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY,
    } = {
      time: DEFAULT_LASSO_LONG_PRESS_TIME,
      extraTime: DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME,
      delay: DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY,
    },
  ) => {
    longPressIsStarting = true;

    const mainStyle = getComputedStyle(longPress);
    longPress.style.color = mainStyle.color;
    longPress.style.top = `${y}px`;
    longPress.style.left = `${x}px`;
    longPress.style.animation = 'none';

    const circleStyle = getComputedStyle(longPressCircle);
    longPressCircle.style.clipPath = circleStyle.clipPath;
    longPressCircle.style.opacity = circleStyle.opacity;
    longPressCircle.style.animation = 'none';

    const effectStyle = getCurrentTransformStyle(longPressEffect);
    longPressEffect.style.opacity = effectStyle.opacity;
    longPressEffect.style.transform = `scale(${effectStyle.scale})`;
    longPressEffect.style.animation = 'none';

    const circleLeftStyle = getCurrentTransformStyle(longPressCircleLeft);
    longPressCircleLeft.style.transform = `rotate(${circleLeftStyle.rotate}deg)`;
    longPressCircleLeft.style.animation = 'none';

    const circleRightStyle = getCurrentTransformStyle(longPressCircleRight);
    longPressCircleRight.style.transform = `rotate(${circleRightStyle.rotate}deg)`;
    longPressCircleRight.style.animation = 'none';

    nextAnimationFrame().then(() => {
      if (!longPressIsStarting) {
        return;
      }

      if (longPressCircleInAnimationRuleIndex !== null) {
        removeRule(longPressCircleInAnimationRuleIndex);
      }
      if (longPressCircleRightInAnimationRuleIndex !== null) {
        removeRule(longPressCircleRightInAnimationRuleIndex);
      }
      if (longPressCircleLeftInAnimationRuleIndex !== null) {
        removeRule(longPressCircleLeftInAnimationRuleIndex);
      }
      if (longPressEffectInAnimationRuleIndex !== null) {
        removeRule(longPressEffectInAnimationRuleIndex);
      }
      if (longPressMainInAnimationRuleIndex !== null) {
        removeRule(longPressMainInAnimationRuleIndex);
      }

      const { rules, names } = createLongPressInAnimations({
        time,
        extraTime,
        delay,
        currentColor: mainStyle.color || 'currentcolor',
        targetColor: longPress.dataset.activeColor,
        effectOpacity: effectStyle.opacity || 0,
        effectScale: effectStyle.scale || 0,
        circleLeftRotation: circleLeftStyle.rotate || 0,
        circleRightRotation: circleRightStyle.rotate || 0,
        circleClipPath: circleStyle.clipPath || 'inset(0 0 0 50%)',
        circleOpacity: circleStyle.opacity || 0,
      });

      longPressMainInAnimationRuleIndex = addRule(rules.main);
      longPressEffectInAnimationRuleIndex = addRule(rules.effect);
      longPressCircleLeftInAnimationRuleIndex = addRule(rules.circleLeft);
      longPressCircleRightInAnimationRuleIndex = addRule(rules.circleRight);
      longPressCircleInAnimationRuleIndex = addRule(rules.circle);

      longPress.style.animation = names.main;
      longPressEffect.style.animation = names.effect;
      longPressCircleLeft.style.animation = names.circleLeft;
      longPressCircleRight.style.animation = names.circleRight;
      longPressCircle.style.animation = names.circle;
    });
  };

  const hideLongPressIndicator = (
    { time = DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME } = {
      time: DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME,
    },
  ) => {
    if (!longPressIsStarting) {
      return;
    }

    longPressIsStarting = false;

    const mainStyle = getComputedStyle(longPress);
    longPress.style.color = mainStyle.color;
    longPress.style.animation = 'none';

    const circleStyle = getComputedStyle(longPressCircle);
    longPressCircle.style.clipPath = circleStyle.clipPath;
    longPressCircle.style.opacity = circleStyle.opacity;
    longPressCircle.style.animation = 'none';

    const effectStyle = getCurrentTransformStyle(longPressEffect);
    longPressEffect.style.opacity = effectStyle.opacity;
    longPressEffect.style.transform = `scale(${effectStyle.scale})`;
    longPressEffect.style.animation = 'none';

    // The first half of the circle animation, the clip-path is set to `inset(0px 0px 0px 50%)`.
    // In the second half it's set to `inset(0px)`. Hence we can look at the second to last
    // character to determine if the animatation has progressed passed half time.
    const isAnimatedMoreThan50Percent =
      circleStyle.clipPath.slice(-2, -1) === 'x';

    const circleLeftStyle = getCurrentTransformStyle(
      longPressCircleLeft,
      isAnimatedMoreThan50Percent,
    );
    longPressCircleLeft.style.transform = `rotate(${circleLeftStyle.rotate}deg)`;
    longPressCircleLeft.style.animation = 'none';

    const circleRightStyle = getCurrentTransformStyle(longPressCircleRight);
    longPressCircleRight.style.transform = `rotate(${circleRightStyle.rotate}deg)`;
    longPressCircleRight.style.animation = 'none';

    nextAnimationFrame().then(() => {
      if (longPressCircleOutAnimationRuleIndex !== null) {
        removeRule(longPressCircleOutAnimationRuleIndex);
      }
      if (longPressCircleRightOutAnimationRuleIndex !== null) {
        removeRule(longPressCircleRightOutAnimationRuleIndex);
      }
      if (longPressCircleLeftOutAnimationRuleIndex !== null) {
        removeRule(longPressCircleLeftOutAnimationRuleIndex);
      }
      if (longPressEffectOutAnimationRuleIndex !== null) {
        removeRule(longPressEffectOutAnimationRuleIndex);
      }
      if (longPressMainOutAnimationRuleIndex !== null) {
        removeRule(longPressMainOutAnimationRuleIndex);
      }

      const { rules, names } = createLongPressOutAnimations({
        time,
        currentColor: mainStyle.color || 'currentcolor',
        targetColor: longPress.dataset.color,
        effectOpacity: effectStyle.opacity || 0,
        effectScale: effectStyle.scale || 0,
        circleLeftRotation: circleLeftStyle.rotate || 0,
        circleRightRotation: circleRightStyle.rotate || 0,
        circleClipPath: circleStyle.clipPath || 'inset(0px)',
        circleOpacity: circleStyle.opacity || 1,
      });

      longPressMainOutAnimationRuleIndex = addRule(rules.main);
      longPressEffectOutAnimationRuleIndex = addRule(rules.effect);
      longPressCircleLeftOutAnimationRuleIndex = addRule(rules.circleLeft);
      longPressCircleRightOutAnimationRuleIndex = addRule(rules.circleRight);
      longPressCircleOutAnimationRuleIndex = addRule(rules.circle);

      longPress.style.animation = names.main;
      longPressEffect.style.animation = names.effect;
      longPressCircleLeft.style.animation = names.circleLeft;
      longPressCircleRight.style.animation = names.circleRight;
      longPressCircle.style.animation = names.circle;
    });
  };

  const draw = () => {
    onDraw(lassoPos, lassoPosFlat);
  };

  const extendFreeform = (point) => {
    lassoPos.push(point);
    lassoPosFlat.push(point[0], point[1]);
  };

  const extendRectangle = (point) => {
    const [x, y] = point;
    const [startX, startY] = lassoPos[0];

    lassoPos[1] = [x, startY];
    lassoPos[2] = [x, y];
    lassoPos[3] = [startX, y];
    lassoPos[4] = [startX, startY];

    lassoPosFlat[2] = x;
    lassoPosFlat[3] = startY;
    lassoPosFlat[4] = x;
    lassoPosFlat[5] = y;
    lassoPosFlat[6] = startX;
    lassoPosFlat[7] = y;
    lassoPosFlat[8] = startX;
    lassoPosFlat[9] = startY;
  };

  const startBrush = (point) => {
    lassoBrushCenterPos.push(point);
  };

  const getNormalizedBrushSize = () =>
    Math.abs(pointNorm([0, 0])[0] - pointNorm([brushSize / 2, 0])[0]);

  const getBrushNormal = (point1, point2, w) => {
    const [x1, y1] = point1;
    const [x2, y2] = point2;

    const dx = x1 - x2;
    const dy = y1 - y2;
    const dn = l2Norm([dx, dy]);

    return [(+dy / dn) * w, (-dx / dn) * w];
  };

  const extendBrush = (point) => {
    const prevPoint = lassoBrushCenterPos.at(-1);

    const width = getNormalizedBrushSize();
    let [nx, ny] = getBrushNormal(point, prevPoint, width);

    const N = lassoBrushCenterPos.length;

    if (N === 1) {
      // In this special case, we have to add the initial two points and normal
      // because when the first brush point was set the direction is undefined.
      const pl = [prevPoint[0] + nx, prevPoint[1] + ny];
      const pr = [prevPoint[0] - nx, prevPoint[1] - ny];

      lassoPos.push(pl, pr);
      lassoPosFlat.push(pl[0], pl[1], pr[0], pr[1]);
      lassoBrushNormals.push([nx, ny]);
    } else {
      // In this case, we have to adjust the previous normal to create a proper
      // line join by taking the middle between the current and previous normal.
      // const prevPrevPoint = lassoBrushCenterPos.at(-2);
      [nx, ny] = getBrushNormal(point, prevPoint, width);

      const nextRawBrushNormals = [...lassoBrushNormals, [nx, ny]];

      // However, to avoid jittery lines we're smoothing the normal
      [nx, ny] = exponentialMovingAverage(nextRawBrushNormals, 1, 10);

      const [pnx, pny] = lassoBrushNormals.at(-1);

      const pnx2 = (nx + pnx) / 2;
      const pny2 = (ny + pny) / 2;

      const pl = [prevPoint[0] + pnx2, prevPoint[1] + pny2];
      const pr = [prevPoint[0] - pnx2, prevPoint[1] - pny2];

      // We're going to replace the previous left and right points
      lassoPos.splice(N - 1, 2, pl, pr);
      lassoPosFlat.splice(2 * (N - 1), 4, pl[0], pl[1], pr[0], pr[1]);
      lassoBrushNormals.splice(N, 1, [pnx2, pny2]);
    }

    const pl = [point[0] + nx, point[1] + ny];
    const pr = [point[0] - nx, point[1] - ny];

    lassoPos.splice(N, 0, pl, pr);
    lassoPosFlat.splice(2 * N, 0, pl[0], pl[1], pr[0], pr[1]);

    lassoBrushCenterPos.push(point);
    lassoBrushNormals.push([nx, ny]);
  };

  let extendLasso = extendFreeform;
  let startLasso = extendFreeform;

  const extend = (currMousePos) => {
    if (prevMousePos) {
      const d = l2PointDist(
        currMousePos[0],
        currMousePos[1],
        prevMousePos[0],
        prevMousePos[1],
      );

      if (d > minDist) {
        prevMousePos = currMousePos;

        extendLasso(pointNorm(currMousePos));

        if (lassoPos.length > 1) {
          draw();
        }
      }
    } else {
      if (!isLasso) {
        isLasso = true;
        onStart();
      }
      prevMousePos = currMousePos;
      const point = pointNorm(currMousePos);
      startLasso(point);
    }
  };

  const extendDb = throttleAndDebounce(extend, minDelay, minDelay);

  const extendPublic = (event, debounced) => {
    const mousePosition = getMousePosition(event);
    if (debounced) {
      return extendDb(mousePosition);
    }
    return extend(mousePosition);
  };

  const clear = () => {
    lassoPos = [];
    lassoPosFlat = [];
    lassoBrushCenterPos = [];
    lassoBrushNormals = [];
    prevMousePos = undefined;
    draw();
  };

  const initiatorClickHandler = (event) => {
    showInitiator(event);
  };

  const initiatorMouseDownHandler = () => {
    isMouseDown = true;
    isLasso = true;
    clear();
    onStart();
  };

  const initiatorMouseLeaveHandler = () => {
    hideInitiator();
  };

  const end = ({ merge = false, remove = false } = {}) => {
    isLasso = false;

    const currLassoPos = [...lassoPos];
    const currLassoPosFlat = [...lassoPosFlat];

    extendDb.cancel();

    clear();

    // When `currLassoPos` is empty the user didn't actually lasso
    if (currLassoPos.length > 0) {
      onEnd(currLassoPos, currLassoPosFlat, { merge, remove });
    }

    return currLassoPos;
  };

  const setExtendLasso = (newType) => {
    switch (newType) {
      case 'rectangle': {
        type = newType;
        extendLasso = extendRectangle;
        // This is on purpose. The start of a rectangle & freeform are the same
        startLasso = extendFreeform;
        break;
      }

      case 'brush': {
        type = newType;
        extendLasso = extendBrush;
        startLasso = startBrush;
        break;
      }

      default: {
        type = 'freeform';
        extendLasso = extendFreeform;
        startLasso = extendFreeform;
        break;
      }
    }
  };

  const get = (property) => {
    if (property === 'onDraw') {
      return onDraw;
    }
    if (property === 'onStart') {
      return onStart;
    }
    if (property === 'onEnd') {
      return onEnd;
    }
    if (property === 'enableInitiator') {
      return enableInitiator;
    }
    if (property === 'minDelay') {
      return minDelay;
    }
    if (property === 'minDist') {
      return minDist;
    }
    if (property === 'pointNorm') {
      return pointNorm;
    }
    if (property === 'type') {
      return type;
    }
    if (property === 'brushSize') {
      return brushSize;
    }
  };

  const set = ({
    onDraw: newOnDraw = null,
    onStart: newOnStart = null,
    onEnd: newOnEnd = null,
    enableInitiator: newEnableInitiator = null,
    initiatorParentElement: newInitiatorParentElement = null,
    longPressIndicatorParentElement: newLongPressIndicatorParentElement = null,
    minDelay: newMinDelay = null,
    minDist: newMinDist = null,
    pointNorm: newPointNorm = null,
    type: newType = null,
    brushSize: newBrushSize = null,
  } = {}) => {
    onDraw = ifNotNull(newOnDraw, onDraw);
    onStart = ifNotNull(newOnStart, onStart);
    onEnd = ifNotNull(newOnEnd, onEnd);
    enableInitiator = ifNotNull(newEnableInitiator, enableInitiator);
    minDelay = ifNotNull(newMinDelay, minDelay);
    minDist = ifNotNull(newMinDist, minDist);
    pointNorm = ifNotNull(newPointNorm, pointNorm);
    brushSize = ifNotNull(newBrushSize, brushSize);

    if (
      newInitiatorParentElement !== null &&
      newInitiatorParentElement !== initiatorParentElement
    ) {
      initiatorParentElement.removeChild(initiator);
      newInitiatorParentElement.appendChild(initiator);
      initiatorParentElement = newInitiatorParentElement;
    }

    if (
      newLongPressIndicatorParentElement !== null &&
      newLongPressIndicatorParentElement !== longPressIndicatorParentElement
    ) {
      longPressIndicatorParentElement.removeChild(longPress);
      newLongPressIndicatorParentElement.appendChild(longPress);
      longPressIndicatorParentElement = newLongPressIndicatorParentElement;
    }

    if (enableInitiator) {
      initiator.addEventListener('click', initiatorClickHandler);
      initiator.addEventListener('mousedown', initiatorMouseDownHandler);
      initiator.addEventListener('mouseleave', initiatorMouseLeaveHandler);
    } else {
      initiator.removeEventListener('mousedown', initiatorMouseDownHandler);
      initiator.removeEventListener('mouseleave', initiatorMouseLeaveHandler);
    }

    if (newType !== null) {
      setExtendLasso(newType);
    }
  };

  const destroy = () => {
    initiatorParentElement.removeChild(initiator);
    longPressIndicatorParentElement.removeChild(longPress);
    window.removeEventListener('mouseup', mouseUpHandler);
    initiator.removeEventListener('click', initiatorClickHandler);
    initiator.removeEventListener('mousedown', initiatorMouseDownHandler);
    initiator.removeEventListener('mouseleave', initiatorMouseLeaveHandler);
  };

  const withPublicMethods = () => (self) =>
    assign(self, {
      clear,
      destroy,
      end,
      extend: extendPublic,
      get,
      set,
      showInitiator,
      hideInitiator,
      showLongPressIndicator,
      hideLongPressIndicator,
    });

  initiatorParentElement.appendChild(initiator);
  longPressIndicatorParentElement.appendChild(longPress);

  set({
    onDraw,
    onStart,
    onEnd,
    enableInitiator,
    initiatorParentElement,
    type,
    brushSize,
  });

  return pipe(
    withStaticProperty('initiator', initiator),
    withStaticProperty('longPressIndicator', longPress),
    withPublicMethods(),
    withConstructor(createLasso),
  )({});
};

/**
 * Check if all GL extensions are supported and enabled and warn otherwise
 * @param   {import('regl').Regl}  regl  Regl instance to be tested
 * @param   {boolean}  silent  If `true` the function will not print `console.warn` statements
 * @return  {boolean}  If `true` all required GL extensions are supported
 */
const checkReglExtensions = (regl, silent) => {
  if (!regl) {
    return false;
  }
  return GL_EXTENSIONS.reduce((every, extension) => {
    if (!regl.hasExtension(extension)) {
      if (!silent) {
        // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning
        console.warn(
          `WebGL: ${extension} extension not supported. Scatterplot might not render properly`,
        );
      }
      return false;
    }
    return every;
  }, true);
};

/**
 * Create a new Regl instance with `GL_EXTENSIONS` enables
 * @param   {HTMLCanvasElement}  canvas  Canvas element to be rendered on
 * @return  {import('regl').Regl}  New Regl instance
 */
const createRegl = (canvas) => {
  const gl = canvas.getContext('webgl', {
    antialias: true,
    preserveDrawingBuffer: true,
  });
  const extensions = [];

  // Needed to run the tests properly as the headless-gl doesn't support all
  // extensions, which is fine for the functional tests.
  for (const extension of GL_EXTENSIONS) {
    if (gl.getExtension(extension)) {
      extensions.push(extension);
    } else {
      // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning
      console.warn(
        `WebGL: ${extension} extension not supported. Scatterplot might not render properly`,
      );
    }
  }

  return createOriginalRegl({ gl, extensions });
};

/**
 * L2 distance between a pair of 2D points
 * @param   {number}  x1  X coordinate of the first point
 * @param   {number}  y1  Y coordinate of the first point
 * @param   {number}  x2  X coordinate of the second point
 * @param   {number}  y2  Y coordinate of the first point
 * @return  {number}  L2 distance
 */
const dist = (x1, y1, x2, y2) =>
  Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);

/**
 * Get the bounding box of a set of 2D positions
 * @param   {array}  positions2d  2D positions to be checked
 * @return  {array}  Quadruple of form `[xMin, yMin, xMax, yMax]` defining the
 *  bounding box
 */
// biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox
const getBBox = (positions2d) => {
  let xMin = Number.POSITIVE_INFINITY;
  let xMax = Number.NEGATIVE_INFINITY;
  let yMin = Number.POSITIVE_INFINITY;
  let yMax = Number.NEGATIVE_INFINITY;

  for (let i = 0; i < positions2d.length; i += 2) {
    xMin = positions2d[i] < xMin ? positions2d[i] : xMin;
    xMax = positions2d[i] > xMax ? positions2d[i] : xMax;
    yMin = positions2d[i + 1] < yMin ? positions2d[i + 1] : yMin;
    yMax = positions2d[i + 1] > yMax ? positions2d[i + 1] : yMax;
  }

  return [xMin, yMin, xMax, yMax];
};

/**
 * Test whether a bounding box is actually specifying an area
 * @param   {array}  bBox  The bounding box to be checked
 * @return  {array}  `true` if the bounding box is valid
 */
// biome-ignore lint/style/useNamingConvention: BBox stands for BoundingBox
const isValidBBox = ([xMin, yMin, xMax, yMax]) =>
  Number.isFinite(xMin) &&
  Number.isFinite(yMin) &&
  Number.isFinite(xMax) &&
  Number.isFinite(yMax) &&
  xMax - xMin > 0 &&
  yMax - yMin > 0;

const REGEX_HEX_TO_RGB = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;

/**
 * Convert a HEX-encoded color to an RGB-encoded color
 * @param   {string}  hex  HEX-encoded color string.
 * @param   {boolean}  isNormalize  If `true` the returned RGB values will be
 *   normalized to `[0,1]`.
 * @return  {array}  Triple holding the RGB values.
 */
const hexToRgb = (hex, isNormalize = false) =>
  hex
    .replace(REGEX_HEX_TO_RGB, (_m, r, g, b) => `#${r}${r}${g}${g}${b}${b}`)
    .substring(1)
    .match(/.{2}/g)
    .map((x) => Number.parseInt(x, 16) / 255 ** isNormalize);

const isConditionalArray = (a, condition, { minLength = 0 } = {}) =>
  Array.isArray(a) && a.length >= minLength && a.every(condition);

const isPositiveNumber = (x) => !Number.isNaN(+x) && +x >= 0;

const isStrictlyPositiveNumber = (x) => !Number.isNaN(+x) && +x > 0;

/**
 * Create a function to limit choices to a predefined list
 * @param   {array}  choices  Array of acceptable choices
 * @param   {*}  defaultOption  Default choice
 * @return  {function}  Function limiting the choices
 */
const limit = (choices, defaultChoice) => (choice) =>
  choices.indexOf(choice) >= 0 ? choice : defaultChoice;

/**
 * Promised-based image loading
 * @param {string}  src  Remote image source, i.e., a URL
 * @param {boolean} isCrossOrigin If `true` allow loading image from a source of another origin.
 * @return  {Promise<HTMLImageElement>}  Promise resolving to the image once its loaded
 */
const loadImage = (
  src,
  isCrossOrigin = false,
  timeout = DEFAULT_IMAGE_LOAD_TIMEOUT,
) =>
  new Promise((resolve, reject) => {
    const image = new Image();
    if (isCrossOrigin) {
      image.crossOrigin = 'anonymous';
    }
    image.src = src;
    image.onload = () => {
      resolve(image);
    };
    const rejectPromise = () => {
      reject(new Error(IMAGE_LOAD_ERROR));
    };
    image.onerror = rejectPromise;
    setTimeout(rejectPromise, timeout);
  });

/**
 * @deprecated Please use `scatterplot.createTextureFromUrl(url)`
 *
 * Create a Regl texture from an URL.
 * @param   {import('regl').Regl}  regl  Regl instance used for creating the texture.
 * @param   {string}  url  Source URL of the image.
 * @return  {Promise<import('regl').Texture2D>}  Promise resolving to the texture object.
 */
const createTextureFromUrl = (
  regl,
  url,
  timeout = DEFAULT_IMAGE_LOAD_TIMEOUT,
) =>
  new Promise((resolve, reject) => {
    loadImage(
      url,
      url.indexOf(window.location.origin) !== 0 && url.indexOf('base64') === -1,
      timeout,
    )
      .then((image) => {
        resolve(regl.texture(image));
      })
      .catch((error) => {
        reject(error);
      });
  });

/**
 * Convert a HEX-encoded color to an RGBA-encoded color
 * @param   {string}  hex  HEX-encoded color string.
 * @param   {boolean}  isNormalize  If `true` the returned RGBA values will be
 *   normalized to `[0,1]`.
 * @return  {array}  Triple holding the RGBA values.
 */
const hexToRgba = (hex, isNormalize = false) => [
  ...hexToRgb(hex, isNormalize),
  255 ** !isNormalize,
];

const REGEX_IS_HEX = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i;

/**
 * Tests if a string is a valid HEX color encoding
 * @param   {string}  hex  HEX-encoded color string.
 * @return  {boolean}  If `true` the string is a valid HEX color encoding.
 */
const isHex = (hex) => REGEX_IS_HEX.test(hex);

/**
 * Tests if a number is in `[0,1]`.
 * @param   {number}  x  Number to be tested.
 * @return  {boolean}  If `true` the number is in `[0,1]`.
 */
const isNormFloat = (x) => x >= 0 && x <= 1;

/**
 * Tests if an array consist of normalized numbers that are in `[0,1]` only.
 * @param   {array}  a  Array to be tested
 * @return  {boolean}  If `true` the array contains only numbers in `[0,1]`.
 */
const isNormFloatArray = (a) => Array.isArray(a) && a.every(isNormFloat);

/**
 * Computes the cross product to determine the orientation of three points
 * @param   {number}  x1  X-coordinate of first point
 * @param   {number}  y1  Y-coordinate of first point
 * @param   {number}  x2  X-coordinate of second point
 * @param   {number}  y2  Y-coordinate of second point
 * @param   {number}  px  X-coordinate of test point
 * @param   {number}  py  Y-coordinate of test point
 * @return  {number}  Positive if counterclockwise, negative if clockwise
 */
function crossProduct(x1, y1, x2, y2, px, py) {
  return (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1);
}

/**
 * Determines if a point lies within a polygon using the non-zero winding rule.
 * This handles self-intersecting polygons and overlapping areas correctly.
 * @param   {Array}  polygon  1D list of vertices defining the polygon [x1,y1,x2,y2,...]
 * @param   {Array}  point    Tuple of the form [x,y] to be tested
 * @return  {boolean} True if point lies within the polygon
 */
const isPointInPolygon = (polygon, [px, py] = []) => {
  let winding = 0;

  for (let i = 0, j = polygon.length - 2; i < polygon.length; i += 2) {
    const x1 = polygon[i];
    const y1 = polygon[i + 1];
    const x2 = polygon[j];
    const y2 = polygon[j + 1];

    if (y1 <= py) {
      if (y2 > py) {
        const orientation = crossProduct(x1, y1, x2, y2, px, py);
        if (orientation > 0) {
          winding++;
        }
      }
    } else if (y2 <= py) {
      const orientation = crossProduct(x1, y1, x2, y2, px, py);
      if (orientation < 0) {
        winding--;
      }
    }

    j = i;
  }

  return winding !== 0;
};

/**
 * Tests if a variable is a string
 * @param   {*}  s  Variable to be tested
 * @return  {boolean}  If `true` variable is a string
 */
const isString = (s) => typeof s === 'string' || s instanceof String;

/**
 * Tests if a number is an interger and in `[0,255]`.
 * @param   {number}  x  Number to be tested.
 * @return  {boolean}  If `true` the number is an interger and in `[0,255]`.
 */
const isUint8 = (x) => Number.isInteger(x) && x >= 0 && x <= 255;

/**
 * Tests if an array consist of Uint8 numbers only.
 * @param   {array}  a  Array to be tested.
 * @return  {boolean}  If `true` the array contains only Uint8 numbers.
 */
const isUint8Array = (a) => Array.isArray(a) && a.every(isUint8);

/**
 * Tests if an array is encoding an RGB color.
 * @param   {array}  rgb  Array to be tested
 * @return  {boolean}  If `true` the array hold a triple of Uint8 numbers or
 *   a triple of normalized floats.
 */
const isRgb = (rgb) =>
  rgb.length === 3 && (isNormFloatArray(rgb) || isUint8Array(rgb));

/**
 * Tests if an array is encoding an RGBA color.
 * @param   {array}  rgb  Array to be tested
 * @return  {boolean}  If `true` the array hold a quadruple of Uint8 numbers or
 *   a quadruple of normalized floats.
 */
const isRgba = (rgba) =>
  rgba.length === 4 && (isNormFloatArray(rgba) || isUint8Array(rgba));

/**
 * Test if a color is multiple colors
 * @param   {*}  color  To be tested
 * @return  {boolean}  If `true`, `color` is an array of colors.
 */
const isMultipleColors = (color) =>
  Array.isArray(color) &&
  color.length > 0 &&
  (Array.isArray(color[0]) || isString(color[0]));

/**
 * Test if two arrays contain the same RGBA quadruples
 */
const isSameRgbas = (a, b) => {
  if (!(Array.isArray(a) && Array.isArray(b)) || a.length !== b.length) {
    return false;
  }

  if (a.length === 0) {
    return true;
  }

  // We need to test whether a and b are arrays of RGBA quadruples
  if (!(Array.isArray(a[0]) && Array.isArray(b[0]))) {
    return false;
  }

  return a.every(([r1, g1, b1, a1], i) => {
    const [r2, g2, b2, a2] = b[i];
    return r1 === r2 && g1 === g2 && b1 === b2 && a1 === a2;
  });
};

/**
 * Fast version of `Math.max`. Based on
 *   https://jsperf.com/math-min-max-vs-ternary-vs-if/24 `Math.max` is not
 *   very fast
 * @param   {number}  a  Value A
 * @param   {number}  b  Value B
 * @return  {boolean}  If `true` A is greater than B.
 */
const max = (a, b) => (a > b ? a : b);

/**
 * Fast version of `Math.min`. Based on
 *   https://jsperf.com/math-min-max-vs-ternary-vs-if/24 `Math.max` is not
 *   very fast
 * @param   {number}  a  Value A
 * @param   {number}  b  Value B
 * @return  {boolean}  If `true` A is smaller than B.
 */
const min = (a, b) => (a < b ? a : b);

/**
 * Convert a color to an RGBA color
 * @param   {*}  color  Color to be converted. Currently supports:
 *   HEX, RGB, or RGBA.
 * @param   {boolean}  isNormalize  If `true` the returned RGBA values will be
 *   normalized to `[0,1]`.
 * @return  {array}  Quadruple defining an RGBA color.
 */
const toRgba = (color, shouldNormalize) => {
  if (isRgba(color)) {
    const isNormalized = isNormFloatArray(color);
    if (
      (shouldNormalize && isNormalized) ||
      !(shouldNormalize || isNormalized)
    ) {
      return color;
    }
    if (shouldNormalize && !isNormalized) {
      return color.map((x) => x / 255);
    }
    return color.map((x) => x * 255);
  }

  if (isRgb(color)) {
    const base = 255 ** !shouldNormalize;
    const isNormalized = isNormFloatArray(color);

    if (
      (shouldNormalize && isNormalized) ||
      !(shouldNormalize || isNormalized)
    ) {
      return [...color, base];
    }
    if (shouldNormalize && !isNormalized) {
      return [...color.map((x) => x / 255), base];
    }
    return [...color.map((x) => x * 255), base];
  }

  if (isHex(color)) {
    return hexToRgba(color, shouldNormalize);
  }

  // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning
  console.warn(
    'Only HEX, RGB, and RGBA are handled by this function. Returning white instead.',
  );
  return shouldNormalize ? [1, 1, 1, 1] : [255, 255, 255, 255];
};

/**
 * Flip the key-value pairs of an object
 * @param {object} obj - Object to be flipped
 * @return {object} Flipped object
 */
const flipObj = (obj) =>
  Object.entries(obj).reduce((out, [key, value]) => {
    if (out[value]) {
      out[value] = [...out[value], key];
    } else {
      out[value] = key;
    }
    return out;
  }, {});

const rgbBrightness = (rgb) =>
  0.21 * rgb[0] + 0.72 * rgb[1] + 0.07 * rgb[2];

/**
 * Clip a number between min and max
 * @param {number} value - The value to be clipped
 * @param {number} minValue - The minimum value
 * @param {number} maxValue - The maximum value
 * @return {number} The clipped value
 */
const clip = (value, minValue, maxValue) =>
  Math.min(maxValue, Math.max(minValue, value));

/**
 * Convert object- or array-oriented points to array-oriented points
 * @param {import('./types').Points} points - The point data
 * @return {number[][]} Array-oriented points
 */
const toArrayOrientedPoints = (points) =>
  new Promise((resolve, reject) => {
    if (!points || Array.isArray(points)) {
      resolve(points);
    } else {
      const length =
        Array.isArray(points.x) || ArrayBuffer.isView(points.x)
          ? points.x.length
          : 0;

      const getX =
        (Array.isArray(points.x) || ArrayBuffer.isView(points.x)) &&
        ((i) => points.x[i]);
      const getY =
        (Array.isArray(points.y) || ArrayBuffer.isView(points.y)) &&
        ((i) => points.y[i]);
      const getL =
        (Array.isArray(points.line) || ArrayBuffer.isView(points.line)) &&
        ((i) => points.line[i]);

      // biome-ignore lint/style/useNamingConvention: LO stands for line and order
      const getLO =
        (Array.isArray(points.lineOrder) ||
          ArrayBuffer.isView(points.lineOrder)) &&
        ((i) => points.lineOrder[i]);

      const components = Object.keys(points);
      const getZ = (() => {
        const z = components.find((c) => Z_NAMES.has(c));
        return (
          z &&
          (Array.isArray(points[z]) || ArrayBuffer.isView(points[z])) &&
          ((i) => points[z][i])
        );
      })();
      const getW = (() => {
        const w = components.find((c) => W_NAMES.has(c));
        return (
          w &&
          (Array.isArray(points[w]) || ArrayBuffer.isView(points[w])) &&
          ((i) => points[w][i])
        );
      })();

      if (getX && getY && getZ && getW && getL && getLO) {
        resolve(
          points.x.map((x, i) => [
            x,
            getY(i),
            getZ(i),
            getW(i),
            getL(i),
            getLO(i),
          ]),
        );
      } else if (getX && getY && getZ && getW && getL) {
        resolve(
          Array.from({ length }, (_, i) => [
            getX(i),
            getY(i),
            getZ(i),
            getW(i),
            getL(i),
          ]),
        );
      } else if (getX && getY && getZ && getW) {
        resolve(
          Array.from({ length }, (_, i) => [
            getX(i),
            getY(i),
            getZ(i),
            getW(i),
          ]),
        );
      } else if (getX && getY && getZ) {
        resolve(Array.from({ length }, (_, i) => [getX(i), getY(i), getZ(i)]));
      } else if (getX && getY) {
        resolve(Array.from({ length }, (_, i) => [getX(i), getY(i)]));
      } else {
        reject(new Error('You need to specify at least x and y'));
      }
    }
  });

const isHorizontalLine = (annotation) =>
  Number.isFinite(annotation.y) && !('x' in annotation);

const isVerticalLine = (annotation) =>
  Number.isFinite(annotation.x) && !('y' in annotation);

const isDomRect = (annotation) =>
  Number.isFinite(annotation.x) &&
  Number.isFinite(annotation.y) &&
  Number.isFinite(annotation.width) &&
  Number.isFinite(annotation.height);

const isRect = (annotation) =>
  Number.isFinite(annotation.x1) &&
  Number.isFinite(annotation.y1) &&
  Number.isFinite(annotation.x2) &&
  Number.isFinite(annotation.x2);

const isPolygon = (annotation) =>
  'vertices' in annotation && annotation.vertices.length > 1;

const insertionSort = (array) => {
  const end = array.length;
  for (let i = 1; i < end; i++) {
    // Choosing the first element in our unsorted subarray
    const current = array[i];
    // The last element of our sorted subarray
    let j = i - 1;
    while (j > -1 && current < array[j]) {
      array[j + 1] = array[j];
      j--;
    }
    array[j + 1] = current;
  }
  return array;
};

const createRenderer = (
  /** @type {Partial<import('./types').RendererOptions>} */ options = {},
) => {
  let {
    regl,
    canvas = document.createElement('canvas'),
    gamma = DEFAULT_GAMMA,
  } = options;

  let isDestroyed = false;

  if (!regl) {
    regl = createRegl(canvas);
  }

  const isSupportingAllGlExtensions = checkReglExtensions(regl);

  const fboRes = [canvas.width, canvas.height];
  const fbo = regl.framebuffer({
    width: fboRes[0],
    height: fboRes[1],
    colorFormat: 'rgba',
    colorType: 'float',
  });

  /**
   * Render the float32 framebuffer to the internal canvas
   *
   * From https://observablehq.com/@rreusser/selecting-the-right-opacity-for-2d-point-clouds
   */
  const renderToCanvas = regl({
    vert: `
      precision highp float;
      attribute vec2 xy;
      void main () {
        gl_Position = vec4(xy, 0, 1);
      }`,
    frag: `
      precision highp float;
      uniform vec2 srcRes;
      uniform sampler2D src;
      uniform float gamma;

      vec3 approxLinearToSRGB (vec3 rgb, float gamma) {
        return pow(clamp(rgb, vec3(0), vec3(1)), vec3(1.0 / gamma));
      }

      void main () {
        vec4 color = texture2D(src, gl_FragCoord.xy / srcRes);
        gl_FragColor = vec4(approxLinearToSRGB(color.rgb, gamma), color.a);
      }`,
    attributes: {
      xy: [-4, -4, 4, -4, 0, 4],
    },
    uniforms: {
      src: () => fbo,
      srcRes: () => fboRes,
      gamma: () => gamma,
    },
    count: 3,
    depth: { enable: false },
    blend: {
      enable: true,
      func: {
        // biome-ignore lint/style/useNamingConvention: Regl internal
        srcRGB: 'one',
        srcAlpha: 'one',
        // biome-ignore lint/style/useNamingConvention: Regl internal
        dstRGB: 'one minus src alpha',
        dstAlpha: 'one minus src alpha',
      },
    },
  });

  /**
   * Copy the pixels from the internal canvas onto the target canvas
   */
  const copyTo = (targetCanvas) => {
    const ctx = targetCanvas.getContext('2d');
    ctx.clearRect(0, 0, targetCanvas.width, targetCanvas.height);
    ctx.drawImage(
      canvas,
      (canvas.width - targetCanvas.width) / 2,
      (canvas.height - targetCanvas.height) / 2,
      targetCanvas.width,
      targetCanvas.height,
      0,
      0,
      targetCanvas.width,
      targetCanvas.height,
    );
  };

  /**
   * The render function
   */
  const render = (
    /** @type {(): void} */ draw,
    /** @type {HTMLCanvasElement} */ targetCanvas,
  ) => {
    // Clear internal canvas
    regl.clear(CLEAR_OPTIONS);
    fbo.use(() => {
      // Clear framebuffer
      regl.clear(CLEAR_OPTIONS);
      draw();
    });
    renderToCanvas();
    copyTo(targetCanvas);
  };

  /**
   * Update Regl's viewport, drawingBufferWidth, and drawingBufferHeight
   *
   * @description Call this method after the viewport has changed, e.g., width
   * or height have been altered
   */
  const refresh = () => {
    regl.poll();
  };

  const drawFns = new Set();

  /**
   * Register an draw function that is going to be invoked on every animation
   * frame.
   */
  const onFrame = (/** @type {(): void} */ draw) => {
    drawFns.add(draw);
    return () => {
      drawFns.delete(draw);
    };
  };

  const frame = regl.frame(() => {
    const iterator = drawFns.values();
    let result = iterator.next();
    while (!result.done) {
      result.value(); // The draw function
      result = iterator.next();
    }
  });

  const resize = (
    /** @type {number} */ customWidth,
    /** @type {number} */ customHeight,
  ) => {
    // We need to limit the width and height by the screen size to prevent
    // a bug in VSCode where the window height is said to be taller than the
    // screen height. The problem with too large dimensions is that at some
    // point WebGL will break down because there's an upper limit on how large
    // any buffer and texture can be. It also harms the performance quite a bit.
    //
    // By restricting the widht/height to the screen size we should have a safe
    // upper limit for the canvas size.
    //
    // @see
    // https://github.com/microsoft/vscode/issues/225808
    // https://github.com/flekschas/jupyter-scatter/issues/37
    const width =
      customWidth === undefined
        ? Math.min(window.innerWidth, window.screen.availWidth)
        : customWidth;
    const height =
      customHeight === undefined
        ? Math.min(window.innerHeight, window.screen.availHeight)
        : customHeight;
    canvas.width = width * window.devicePixelRatio;
    canvas.height = height * window.devicePixelRatio;
    fboRes[0] = canvas.width;
    fboRes[1] = canvas.height;
    fbo.resize(...fboRes);
  };

  const resizeHandler = () => {
    resize();
  };

  if (!options.canvas) {
    window.addEventListener('resize', resizeHandler);
    window.addEventListener('orientationchange', resizeHandler);
    resize();
  }

  /**
   * Destroy the renderer to free resources and cancel animation frames
   */
  const destroy = () => {
    isDestroyed = true;
    window.removeEventListener('resize', resizeHandler);
    window.removeEventListener('orientationchange', resizeHandler);
    frame.cancel();
    canvas = undefined;
    regl.destroy();
    regl = undefined;
  };

  return {
    /**
     * Get the associated canvas element
     * @return {HTMLCanvasElement} The associated canvas element
     */
    get canvas() {
      return canvas;
    },
    /**
     * Get the associated Regl instance
     * @return {import('regl').Regl} The associated Regl instance
     */
    get regl() {
      return regl;
    },
    /**
     * Get the gamma value
     * @return {number} The gamma value
     */
    get gamma() {
      return gamma;
    },
    /**
     * Set gamma to a new value
     * @param {number} newGamma - The new gamma value
     */
    set gamma(newGamma) {
      gamma = +newGamma;
    },
    /**
     * Get whether the browser supports all necessary WebGL features
     * @return {boolean} If `true` the browser supports all necessary WebGL features
     */
    get isSupported() {
      return isSupportingAllGlExtensions;
    },
    /**
     * Get whether the renderer (and its Regl instance) is destroyed
     * @return {boolean} If `true` the renderer is destroyed
     */
    get isDestroyed() {
      return isDestroyed;
    },
    render,
    resize,
    onFrame,
    refresh,
    destroy,
  };
};

const FRAGMENT_SHADER$2 = `
precision mediump float;

uniform sampler2D texture;

varying vec2 uv;

void main () {
  gl_FragColor = texture2D(texture, uv);
}
`;

const VERTEX_SHADER = `
precision mediump float;

uniform mat4 modelViewProjection;

attribute vec2 position;

varying vec2 uv;

void main () {
  uv = position;
  gl_Position = modelViewProjection * vec4(-1.0 + 2.0 * uv.x, 1.0 - 2.0 * uv.y, 0, 1);
}
`;

const FRAGMENT_SHADER$1 = `precision highp float;

varying vec4 color;

void main() {
  gl_FragColor = color;
}
`;

const SHADER$1 = `precision highp float;

uniform sampler2D startStateTex;
uniform sampler2D endStateTex;
uniform float t;

varying vec2 particleTextureIndex;

void main() {
  // Interpolate x, y, and value
  vec3 start = texture2D(startStateTex, particleTextureIndex).xyw;
  vec3 end = texture2D(endStateTex, particleTextureIndex).xyw;
  vec3 curr = start * (1.0 - t) + end * t;

  // The category cannot be interpolated
  float endCategory = texture2D(endStateTex, particleTextureIndex).z;

  gl_FragColor = vec4(curr.xy, endCategory, curr.z);
}`;

const SHADER = `precision highp float;

attribute vec2 position;
varying vec2 particleTextureIndex;

void main() {
  // map normalized device coords to texture coords
  particleTextureIndex = 0.5 * (1.0 + position);

  gl_Position = vec4(position, 0, 1);
}`;

const FRAGMENT_SHADER = `
precision highp float;

uniform float antiAliasing;

varying vec4 color;
varying float finalPointSize;

float linearstep(float edge0, float edge1, float x) {
  return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
}

void main() {
  vec2 c = gl_PointCoord * 2.0 - 1.0;
  float sdf = length(c) * finalPointSize;
  float alpha = linearstep(finalPointSize + antiAliasing, finalPointSize - antiAliasing, sdf);

  gl_FragColor = vec4(color.rgb, alpha * color.a);
}
`;

const createVertexShader = (globalState) => `
precision highp float;

uniform sampler2D colorTex;
uniform float colorTexRes;
uniform float colorTexEps;
uniform sampler2D stateTex;
uniform float stateTexRes;
uniform float stateTexEps;
uniform float devicePixelRatio;
uniform sampler2D encodingTex;
uniform float encodingTexRes;
uniform float encodingTexEps;
uniform float pointSizeExtra;
uniform float pointOpacityMax;
uniform float pointOpacityScale;
uniform float numPoints;
uniform float globalState;
uniform float isColoredByZ;
uniform float isColoredByW;
uniform float isOpacityByZ;
uniform float isOpacityByW;
uniform float isOpacityByDensity;
uniform float isSizedByZ;
uniform float isSizedByW;
uniform float isPixelAligned;
uniform float colorMultiplicator;
uniform float opacityMultiplicator;
uniform float opacityDensity;
uniform float sizeMultiplicator;
uniform float numColorStates;
uniform float pointScale;
uniform float drawingBufferWidth;
uniform float drawingBufferHeight;
uniform mat4 modelViewProjection;

attribute vec2 stateIndex;

varying vec4 color;
varying float finalPointSize;

void main() {
  vec4 state = texture2D(stateTex, stateIndex);

  if (isPixelAligned < 0.5) {
    gl_Position = modelViewProjection * vec4(state.x, state.y, 0.0, 1.0);
  } else {
    vec4 clipSpacePosition = modelViewProjection * vec4(state.x, state.y, 0.0, 1.0);
    vec2 ndcPosition = clipSpacePosition.xy / clipSpacePosition.w;
    vec2 pixelPos = 0.5 * (ndcPosition + 1.0) * vec2(drawingBufferWidth, drawingBufferHeight);
    pixelPos = floor(pixelPos + 0.5); // Snap to nearest pixel
    vec2 snappedPosition = (pixelPos / vec2(drawingBufferWidth, drawingBufferHeight)) * 2.0 - 1.0;
    gl_Position = vec4(snappedPosition, 0.0, 1.0);
  }


  // Determine color index
  float colorIndexZ =  isColoredByZ * floor(state.z * colorMultiplicator);
  float colorIndexW =  isColoredByW * floor(state.w * colorMultiplicator);

  // Multiply by the number of color states per color
  // I.e., normal, active, hover, background, etc.
  float colorIndex = (colorIndexZ + colorIndexW) * numColorStates;

  // Half a "pixel" or "texel" in texture coordinates
  float colorLinearIndex = colorIndex + globalState;

  // Need to add cEps here to avoid floating point issue that can lead to
  // dramatic changes in which color is loaded as floor(3/2.9999) = 1 but
  // floor(3/3.0001) = 0!
  float colorRowIndex = floor((colorLinearIndex + colorTexEps) / colorTexRes);

  vec2 colorTexIndex = vec2(
    (colorLinearIndex / colorTexRes) - colorRowIndex + colorTexEps,
    colorRowIndex / colorTexRes + colorTexEps
  );

  color = texture2D(colorTex, colorTexIndex);

  // Retrieve point size
  float pointSizeIndexZ = isSizedByZ * floor(state.z * sizeMultiplicator);
  float pointSizeIndexW = isSizedByW * floor(state.w * sizeMultiplicator);
  float pointSizeIndex = pointSizeIndexZ + pointSizeIndexW;

  float pointSizeRowIndex = floor((pointSizeIndex + encodingTexEps) / encodingTexRes);
  vec2 pointSizeTexIndex = vec2(
    (pointSizeIndex / encodingTexRes) - pointSizeRowIndex + encodingTexEps,
    pointSizeRowIndex / encodingTexRes + encodingTexEps
  );
  float pointSize = texture2D(encodingTex, pointSizeTexIndex).x;

  // Retrieve opacity
  ${
    (() => {
      // Drawing the inner border of selected points
      if (globalState === 3) return '';

      // Draw points with opacity encoding or dynamic opacity
      return `
        if (isOpacityByDensity < 0.5) {
          float opacityIndexZ = isOpacityByZ * floor(state.z * opacityMultiplicator);
          float opacityIndexW = isOpacityByW * floor(state.w * opacityMultiplicator);
          float opacityIndex = opacityIndexZ + opacityIndexW;

          float opacityRowIndex = floor((opacityIndex + encodingTexEps) / encodingTexRes);
          vec2 opacityTexIndex = vec2(
            (opacityIndex / encodingTexRes) - opacityRowIndex + encodingTexEps,
            opacityRowIndex / encodingTexRes + encodingTexEps
          );
          color.a = texture2D(encodingTex, opacityTexIndex)[${1 + globalState}];
        } else {
          color.a = min(1.0, opacityDensity + globalState);
        }
      `;
    })()
  }

  color.a = min(pointOpacityMax, color.a) * pointOpacityScale;
  finalPointSize = (pointSize * pointScale) + pointSizeExtra;
  gl_PointSize = finalPointSize;
}
`;

/* eslint-env worker */
/* eslint no-restricted-globals: 1 */

const worker = function worker() {

  /**
   * Catmull-Rom interpolation
   * @param {number} t - Progress value
   * @param {array} p0 - First point
   * @param {array} p1 - Second point
   * @param {array} p2 - Third point
   * @param {array} p3 - Forth point
   * @return {number} Interpolated value
   */
  const catmullRom = (t, p0, p1, p2, p3) => {
    const v0 = (p2 - p0) * 0.5;
    const v1 = (p3 - p1) * 0.5;
    return (
      (2 * p1 - 2 * p2 + v0 + v1) * t * t * t +
      (-3 * p1 + 3 * p2 - 2 * v0 - v1) * t * t +
      v0 * t +
      p1
    );
  };

  /**
   * Interpolate a point with Catmull-Rom
   * @param {number} t - Progress value
   * @param {array} points - Key points
   * @param {number}  maxPointIdx - Highest point index. Same as array.length - 1
   * @return {array} Interpolated point
   */
  const interpolatePoint = (t, points, maxPointIdx) => {
    const p = maxPointIdx * t;

    const intPoint = Math.floor(p);
    const weight = p - intPoint;

    const p0 = points[Math.max(0, intPoint - 1)];
    const p1 = points[intPoint];
    const p2 = points[Math.min(maxPointIdx, intPoint + 1)];
    const p3 = points[Math.min(maxPointIdx, intPoint + 2)];

    return [
      catmullRom(weight, p0[0], p1[0], p2[0], p3[0]),
      catmullRom(weight, p0[1], p1[1], p2[1], p3[1]),
    ];
  };

  /**
   * Square distance
   * @param {number} x1 - First x coordinate
   * @param {number} y1 - First y coordinate
   * @param {number} x2 - Second x coordinate
   * @param {number} y2 - Second y coordinate
   * @return {number} Distance
   */
  const sqDist = (x1, y1, x2, y2) => (x1 - x2) ** 2 + (y1 - y2) ** 2;

  /**
   * Douglas Peucker square segment distance
   * Implementation from https://github.com/mourner/simplify-js
   * @author Vladimir Agafonkin
   * @copyright Vladimir Agafonkin 2013
   * @license BSD
   * @param {array} p - Point
   * @param {array} p1 - First boundary point
   * @param {array} p2 - Second boundary point
   * @return {number} Distance
   */
  const sqSegDist = (p, p1, p2) => {
    let x = p1[0];
    let y = p1[1];
    let dx = p2[0] - x;
    let dy = p2[1] - y;

    if (dx !== 0 || dy !== 0) {
      const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy);

      if (t > 1) {
        x = p2[0];
        y = p2[1];
      } else if (t > 0) {
        x += dx * t;
        y += dy * t;
      }
    }

    dx = p[0] - x;
    dy = p[1] - y;

    return dx * dx + dy * dy;
  };

  /**
   * Douglas Peucker step function
   * Implementation from https://github.com/mourner/simplify-js
   * @author Vladimir Agafonkin
   * @copyright Vladimir Agafonkin 2013
   * @license BSD
   * @param   {[type]}  points  [description]
   * @param   {[type]}  first  [description]
   * @param   {[type]}  last  [description]
   * @param   {[type]}  tolerance  [description]
   * @param   {[type]}  simplified  [description]
   * @return  {[type]}  [description]
   */
  // biome-ignore lint/style/useNamingConvention: DP stands for Douglas Peucker
  const simplifyDPStep = (points, first, last, tolerance, simplified) => {
    let maxDist = tolerance;
    let index;

    for (let i = first + 1; i < last; i++) {
      const dist = sqSegDist(points[i], points[first], points[last]);

      if (dist > maxDist) {
        index = i;
        maxDist = dist;
      }
    }

    if (maxDist > tolerance) {
      if (index - first > 1) {
        simplifyDPStep(points, first, index, tolerance, simplified);
      }
      simplified.push(points[index]);
      if (last - index > 1) {
        simplifyDPStep(points, index, last, tolerance, simplified);
      }
    }
  };

  /**
   * Douglas Peucker. Implementation from https://github.com/mourner/simplify-js
   * @author Vladimir Agafonkin
   * @copyright Vladimir Agafonkin 2013
   * @license BSD
   * @param {array} points - List of points to be simplified
   * @param {number} tolerance - Tolerance level. Points below this distance level will be ignored
   * @return {array} Simplified point list
   */
  const simplifyDouglasPeucker = (points, tolerance) => {
    const last = points.length - 1;
    const simplified = [points[0]];

    simplifyDPStep(points, 0, last, tolerance, simplified);
    simplified.push(points[last]);

    return simplified;
  };

  /**
   * Interpolate intermediate points between key points
   * @param {array} points - Fixed key points
   * @param {number} options.maxIntPointsPerSegment - Maximum number of points between two key points
   * @param {number} options.tolerance - Simplification tolerance
   * @return {array} Interpolated points including key points
   */
  const interpolatePoints = (
    points,
    { maxIntPointsPerSegment = 100, tolerance = 0.002 } = {},
  ) => {
    const numPoints = points.length;
    const maxPointIdx = numPoints - 1;

    const maxOutPoints = maxPointIdx * maxIntPointsPerSegment + 1;
    const sqTolerance = tolerance ** 2;

    let outPoints = [];
    let prevPoint;

    // Generate interpolated points where the squared-distance between points
    // is larger than sqTolerance
    for (let i = 0; i < numPoints - 1; i++) {
      let segmentPoints = [points[i].slice(0, 2)];
      prevPoint = points[i];

      for (let j = 1; j < maxIntPointsPerSegment; j++) {
        const t = (i * maxIntPointsPerSegment + j) / maxOutPoints;
        const intPoint = interpolatePoint(t, points, maxPointIdx);

        // Check squared distance simplification
        if (
          sqDist(prevPoint[0], prevPoint[1], intPoint[0], intPoint[1]) >
          sqTolerance
        ) {
          segmentPoints.push(intPoint);
          prevPoint = intPoint;
        }
      }

      // Add next key point. Needed for the simplification algorithm
      segmentPoints.push(points[i + 1]);
      // Simplify interpolated points using the douglas-peuckner algorithm
      segmentPoints = simplifyDouglasPeucker(segmentPoints, sqTolerance);
      // Add simplified points without the last key point, which is added
      // anyway in the next segment
      outPoints = outPoints.concat(
        segmentPoints.slice(0, segmentPoints.length - 1),
      );
    }
    outPoints.push(points[points.length - 1].slice(0, 2));

    return outPoints.flat();
  };

  /**
   * Group points by line assignment (the fifth component of a point)
   * @param {array} points - Flat list of points
   * @return {array} List of lists of ordered points by line
   */
  const groupPoints = (points) => {
    const groupedPoints = {};

    const isOrdered = !Number.isNaN(+points[0][5]);
    // biome-ignore lint/complexity/noForEach: somehow for .. of does not work in a worker
    points.forEach((point) => {
      const segId = point[4];

      if (!groupedPoints[segId]) {
        groupedPoints[segId] = [];
      }

      if (isOrdered) {
        groupedPoints[segId][point[5]] = point;
      } else {
        groupedPoints[segId].push(point);
      }
    });

    // The filtering ensures that non-existing array entries are removed
    // biome-ignore lint/complexity/noForEach: somehow for .. of does not work in a worker
    Object.entries(groupedPoints).forEach((idPoints) => {
      groupedPoints[idPoints[0]] = idPoints[1].filter((v) => v);
      // Store the first point as the reference
      groupedPoints[idPoints[0]].reference = idPoints[1][0];
    });

    return groupedPoints;
  };

  self.onmessage = function onmessage(event) {
    const numPoints = event.data.points ? +event.data.points.length : 0;

    if (!numPoints) {
      self.postMessage({ error: new Error('No points provided') });
    }

    event.data.points;

    const groupedPoints = groupPoints(event.data.points);

    self.postMessage({
      points: Object.entries(groupedPoints).reduce(
        (curvePoints, idAndPoints) => {
          curvePoints[idAndPoints[0]] = interpolatePoints(
            idAndPoints[1],
            event.data.options,
          );
          // Make sure the reference is passed on
          curvePoints[idAndPoints[0]].reference = idAndPoints[1].reference;
          return curvePoints;
        },
        {},
      ),
    });
  };
};

const createSplineCurve = (
  points,
  options = { tolerance: 0.002, maxIntPointsPerSegment: 100 },
) =>
  new Promise((resolve, reject) => {
    const worker$1 = createWorker$1(worker);

    worker$1.onmessage = (e) => {
      if (e.data.error) {
        reject(e.data.error);
      } else {
        resolve(e.data.points);
      }
      worker$1.terminate();
    };

    worker$1.postMessage({ points, options });
  });

var version = "1.14.0";

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 = invert(
      scratch,
      multiply(
        scratch,
        projectionLocal,
        multiply(scratch, camera.view, model),
      ),
    );

    // Translate vector
    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 = createLasso(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 = fromScaling([], [1 / viewAspectRatio, 1, 1]);
    projection = fromScaling([], [1 / viewAspectRatio, 1, 1]);
    model = 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)
        ? max$1(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 = () =>
    multiply(pvm, projection, 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: SHADER,
    frag: SHADER$1,

    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 ? FRAGMENT_SHADER$1 : FRAGMENT_SHADER,
      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: FRAGMENT_SHADER$2,
    vert: VERTEX_SHADER,

    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
    multiply(
      scratch,
      projection,
      multiply(scratch, camera.view, model),
    );

    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(),
              widths: getPointConnectionWidths(),
            },
          );
          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 = 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
    multiply(
      scratch,
      projectionLocal,
      multiply(scratch, camera.view, model),
    );

    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 = dom2dCamera(canvas, {
        isFixed: cameraIsFixed,
        isPanInverted: [false, true],
        defaultMouseDownMoveAction:
          mouseMode === MOUSE_MODE_ROTATE ? 'rotate' : 'pan',
      });
    }

    if (initialProperties.cameraView) {
      camera.setView(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(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;
      }
      // 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,
  };
};

/**
 * 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 { checkReglExtensions as checkSupport, createRegl, createRenderer, createSpatialIndex, createTextureFromUrl, createScatterplot as default };