jens-ox/metrics-graphics

View on GitHub
packages/lib/src/js/components/delaunay.js

Summary

Maintainability
A
2 hrs
Test Coverage
import { Delaunay as DelaunayObject } from 'd3-delaunay'
import { mouse } from 'd3-selection'

/**
 * Instantiate a new delaunay computation instance.
 *
 * @param {Object} args argument object.
 * @param {Array} args.points raw data basis for delaunay computations. Can be nested.
 * @param {Function} args.xAccessor function to access the x value for a given data point.
 * @param {Function} args.yAccessor function to access the y value for a given data point.
 * @param {Scale} args.xScale scale used to scale elements in x direction.
 * @param {Scale} args.yScale scale used to scale elements in y direction.
 * @param {Function} [args.onPoint] function called with the array of nearest points on mouse movement. If aggregate is false, the array will contain at most one element.
 * @param {Function} [args.onLeave] function called when the delaunay area is left.
 * @param {Function} [args.onClick] function called with the array of nearest points on mouse click in the delaunay area. If aggregate is false, the array will contain at most one element.
 * @param {Boolean} [args.nested=false] whether or not the points array contains sub-arrays.
 * @param {Boolean} [args.aggregate=false] if multiple points have the same x value and should be shown together, aggregate can be set to true.
 * @param {Function} [args.defined] optional function specifying whether or not to show a given datapoint.
 */
export default class Delaunay {
  points = []
  aggregatedPoints = null
  delaunay = null
  xScale = null
  yScale = null
  xAccessor = null
  yAccessor = null
  aggregate = false

  constructor ({
    points = [],
    xAccessor,
    yAccessor,
    xScale,
    yScale,
    onPoint,
    onLeave,
    onClick,
    nested,
    aggregate,
    defined = null
  }) {
    this.xScale = xScale
    this.yScale = yScale
    this.onPoint = onPoint ?? this.onPoint
    this.onLeave = onLeave ?? this.onLeave
    this.onClick = onClick ?? this.onClick
    this.xAccessor = xAccessor
    this.yAccessor = yAccessor
    this.aggregate = aggregate ?? this.aggregate

    // normalize passed points
    const isNested = nested ?? (Array.isArray(points[0]) && points.length > 1)
    this.normalizePoints({ points, isNested, aggregate, defined })

    // set up delaunay
    this.mountDelaunay(isNested, aggregate)
  }

  /**
   * Create a new delaunay triangulation.
   *
   * @param {Boolean} isNested whether or not the data is nested
   * @param {Boolean} aggregate whether or not to aggregate points based on their x value
   * @returns {void}
   */
  mountDelaunay (isNested, aggregate) {
    this.delaunay = DelaunayObject.from(
      this.points.map(point => ([this.xAccessor(point), isNested && !aggregate ? this.yAccessor(point) : 0]))
    )
  }

  /**
   * Normalize the passed data points.
   *
   * @param {Object} args argument object
   * @param {Array} args.points raw data array
   * @param {Boolean} args.isNested whether or not the points are nested
   * @param {Boolean} args.aggregate whether or not to aggregate points based on their x value
   * @param {Function} [args.defined] optional function specifying whether or not to show a given datapoint.
   * @returns {void}
   */
  normalizePoints ({ points, isNested, aggregate, defined = null }) {
    this.points = isNested
      ? points.map((pointArray, arrayIndex) => pointArray
        .filter(p => !defined || defined(p))
        .map((point, index) => ({
          ...point,
          index,
          arrayIndex
        }))).flat(Infinity)
      : points.flat(Infinity)
        .filter(p => !defined || defined(p))
        .map((p, index) => ({ ...p, index }))

    // if points should be aggregated, hash-map them based on their x accessor value
    if (!aggregate) return
    this.aggregatedPoints = this.points.reduce((acc, val) => {
      const key = JSON.stringify(this.xAccessor(val))
      if (!acc.has(key)) {
        acc.set(key, [val])
      } else {
        acc.set(key, [val, ...acc.get(key)])
      }
      return acc
    }, new Map())
  }

  /**
   * Handle raw mouse movement inside the delaunay rect.
   * Finds the nearest data point(s) and calls onPoint.
   *
   * @param {Number} rawX raw x coordinate of the cursor.
   * @param {Number} rawY raw y coordinate of the cursor.
   * @returns {void}
   */
  gotPoint (rawX, rawY) {
    const x = this.xScale.scaleObject.invert(rawX)
    const y = this.yScale.scaleObject.invert(rawY)

    // find nearest point
    const index = this.delaunay.find(x, y)

    // if points should be aggregated, get all points with the same x value
    if (this.aggregate) {
      this.onPoint(this.aggregatedPoints.get(JSON.stringify(this.xAccessor(this.points[index]))))
    } else {
      this.onPoint([this.points[index]])
    }
  }

  /**
   * Handle raw mouse clicks inside the delaunay rect.
   * Finds the nearest data point(s) and calls onClick.
   *
   * @param {Number} rawX raw x coordinate of the cursor.
   * @param {Number} rawY raw y coordinate of the cursor.
   * @returns {void}
   */
  clickedPoint (rawX, rawY) {
    const x = this.xScale.scaleObject.invert(rawX)
    const y = this.yScale.scaleObject.invert(rawY)

    // find nearest point
    const index = this.delaunay.find(x, y)
    if (this.onClick) this.onClick({ ...this.points[index], index })
  }

  /**
   * Mount the delaunator to a given d3 node.
   *
   * @param {Object} svg d3 selection to mount the delaunay elements to.
   * @returns {void}
   */
  mountTo (svg) {
    svg.on('mousemove', () => {
      const rawCoords = mouse(svg.node())
      this.gotPoint(rawCoords[0], rawCoords[1])
    })
    svg.on('mouseleave', () => {
      this.onLeave()
    })
    svg.on('click', () => {
      const rawCoords = mouse(svg.node())
      this.clickedPoint(rawCoords[0], rawCoords[1])
    })
  }
}