mozilla/metrics-graphics

View on GitHub
lib/src/charts/scatter.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import Delaunay from '../components/delaunay'
import Rug, { RugOrientation } from '../components/rug'
import { makeAccessorFunction } from '../misc/utility'
import { AccessorFunction, LegendSymbol, InteractionFunction, EmptyInteractionFunction } from '../misc/typings'
import Point from '../components/point'
import { TooltipSymbol } from '../components/tooltip'
import AbstractChart, { IAbstractChart } from './abstractChart'

export interface IScatterChart extends IAbstractChart {
  /** accessor specifying the size of a data point. Can be either a string (name of the size field) or a function (receiving a data point and returning its size) */
  sizeAccessor?: string | AccessorFunction

  /** whether or not to generate a rug for the x axis */
  xRug?: boolean

  /** whether or not to generate a rug for the x axis */
  yRug?: boolean
}

interface ActivePoint {
  i: number
  j: number
}

export default class ScatterChart extends AbstractChart {
  points?: Array<any>
  delaunay?: Delaunay
  delaunayPoint?: Point
  sizeAccessor: AccessorFunction
  showXRug: boolean
  xRug?: Rug
  showYRug: boolean
  yRug?: Rug
  _activePoint: ActivePoint = { i: -1, j: -1 }

  constructor({ sizeAccessor = () => 3, xRug = false, yRug = false, ...args }: IScatterChart) {
    super(args)
    this.showXRug = xRug
    this.showYRug = yRug

    this.sizeAccessor = sizeAccessor ? makeAccessorFunction(sizeAccessor) : () => 3

    this.redraw()
  }

  redraw(): void {
    // set up rugs if necessary
    this.mountRugs()

    // set tooltip type
    if (this.tooltip) {
      this.tooltip.update({ legendObject: TooltipSymbol.CIRCLE })
      this.tooltip.hide()
    }

    // set up points
    this.mountPoints()

    // generate delaunator
    this.mountDelaunay()

    // mount legend if any
    this.mountLegend(LegendSymbol.CIRCLE)

    // mount brush if necessary
    this.mountBrush(this.brush)
  }

  /**
   * Mount new rugs.
   */
  mountRugs(): void {
    // if content is not set yet, abort
    if (!this.content) {
      console.error('error: content not set yet')
      return
    }

    if (this.showXRug) {
      this.xRug = new Rug({
        accessor: this.xAccessor,
        scale: this.xScale,
        colors: this.colors,
        data: this.data,
        left: this.plotLeft,
        top: this.innerHeight + this.plotTop + this.buffer,
        orientation: RugOrientation.HORIZONTAL // TODO how to pass tickLength etc?
      })
      this.xRug.mountTo(this.content)
    }
    if (this.showYRug) {
      this.yRug = new Rug({
        accessor: this.yAccessor,
        scale: this.yScale,
        colors: this.colors,
        data: this.data,
        left: this.left,
        top: this.plotTop,
        orientation: RugOrientation.VERTICAL
      })
      this.yRug.mountTo(this.content)
    }
  }

  /**
   * Mount scatter points.
   */
  mountPoints(): void {
    // if container is not set yet, abort
    if (!this.container) {
      console.error('error: container not set yet')
      return
    }

    this.points = this.data.map((pointSet, i) =>
      pointSet.map((data: any) => {
        const point = this.generatePoint({
          data,
          color: this.colors[i],
          radius: this.sizeAccessor(data) as number,
          fillOpacity: 0.3,
          strokeWidth: 1
        })
        point.mountTo(this.container!)
        return point
      })
    )
  }

  /**
   * Handle incoming points from the delaunay triangulation.
   *
   * @returns handler function
   */
  onPointHandler(): InteractionFunction {
    return ([point]) => {
      this.activePoint = { i: point.arrayIndex ?? 0, j: point.index }

      // set tooltip if necessary
      if (!this.tooltip) return
      this.tooltip.update({ data: [point] })
    }
  }

  /**
   * Handle leaving the delaunay area.
   *
   * @returns handler function
   */
  onLeaveHandler(): EmptyInteractionFunction {
    return () => {
      this.activePoint = { i: -1, j: -1 }
      if (this.tooltip) this.tooltip.hide()
    }
  }

  /**
   * Mount new delaunay triangulation instance.
   */
  mountDelaunay(): void {
    // if container is not set yet, abort
    if (!this.container) {
      console.error('error: container not set yet')
      return
    }

    this.delaunayPoint = this.generatePoint({ radius: 3 })
    this.delaunay = new Delaunay({
      points: this.data,
      xAccessor: this.xAccessor,
      yAccessor: this.yAccessor,
      xScale: this.xScale,
      yScale: this.yScale,
      nested: true,
      onPoint: this.onPointHandler(),
      onLeave: this.onLeaveHandler()
    })
    this.delaunay.mountTo(this.container)
  }

  get activePoint() {
    return this._activePoint
  }

  set activePoint({ i, j }: ActivePoint) {
    // abort if points are not set yet
    if (!this.points) {
      console.error('error: cannot set point, as points are not set')
      return
    }

    // if a point was previously set, de-set it
    if (this._activePoint.i !== -1 && this._activePoint.j !== -1) {
      this.points[this._activePoint.i][this._activePoint.j].update({
        fillOpacity: 0.3
      })
    }

    // set state
    this._activePoint = { i, j }

    // set point to active
    if (i !== -1 && j !== -1) this.points[i][j].update({ fillOpacity: 1 })
  }
}