jens-ox/metrics-graphics

View on GitHub
packages/lib/src/js/charts/abstractChart.js

Summary

Maintainability
C
1 day
Test Coverage
import { isArrayOfArrays, isArrayOfObjectsOrEmpty, getWidth, getHeight, randomId, makeAccessorFunction } from '../misc/utility'
import { select, event } from 'd3-selection'
import Scale from '../components/scale'
import Axis from '../components/axis'
import Tooltip from '../components/tooltip'
import Legend from '../components/legend'
import { extent, max } from 'd3-array'
import constants from '../misc/constants'
import Point from '../components/point'
import { brush as d3brush, brushX, brushY } from 'd3-brush'

/**
 * This abstract chart class implements all functionality that is shared between all available chart types.
 * This is not meant to be directly instantiated.
 *
 * @param {Object} args argument object.
 * @param {Array} args.data data that needs to be visualized.
 * @param {String | Object} args.target DOM node to which the graph should be mounted. Either D3 selection or D3 selection specifier.
 * @param {Number} args.width total width of the graph.
 * @param {Number} args.height total height of the graph.
 * @param {Array} [args.markers=[]] markers that should be added to the chart. Each marker object should be accessible through the xAccessor and contain a label field.
 * @param {Array} [args.baselines=[]] baselines that should be added to the chart. Each baseline object should be accessible through the yAccessor and contain a label field.
 * @param {String | Function} [args.xAccessor=d=>d] either name of the field that contains the x value or function that receives a data object and returns its x value.
 * @param {String | Function} [args.yAccessor=d=>d] either name of the field that contains the y value or function that receives a data object and returns its y value.
 * @param {Object} [args.margin={ top: 10, left: 60, right: 20, bottom: 40 }] margin object specifying top, bottom, left and right margin.
 * @param {Number} [args.buffer=10] amount of buffer between the axes and the graph.
 * @param {String | Array} [args.color] custom color scheme for the graph.
 * @param {String | Array} [args.colors=schemeCategory10] alternative to color.
 * @param {Object} [args.xScale] object that can be used to overwrite parameters of the auto-generated x {@link Scale}.
 * @param {Object} [args.yScale] object that can be used to overwrite parameters of the auto-generated y {@link Scale}.
 * @param {Object} [args.xAxis] object that can be used to overwrite parameters of the auto-generated x {@link Axis}.
 * @param {Object} [args.yAxis] object that can be used to overwrite parameters of the auto-generated y {@link Axis}.
 * @param {Boolean} [args.showTooltip] whether or not to show a tooltip.
 * @param {Function} [args.tooltipFunction] function that receives a data object and returns the string displayed as tooltip.
 * @param {Array} [args.legend] names of the sub-arrays of data, used as legend labels.
 * @param {String | Object} [args.legendTarget] DOM node to which the legend should be mounted.
 * @param {Boolean | String} [brush] if truthy, add a bidirectional brush. If `x` or `y`, add one-directional brush.
 */
export default class AbstractChart {
  id = null

  // base chart fields
  data = null
  markers = []
  baselines = []
  target = null
  svg = null
  content = null
  container = null

  // accessors
  xAccessor = null
  yAccessor = null
  colors = []

  // scales
  xDomain = null
  yDomain = null
  xScale = null
  yScale = null

  // axes
  xAxis = null
  xAxisParams = {}
  yAxis = null
  yAxisParams = {}

  // tooltip and legend stuff
  showTooltip = true
  tooltipFunction = null
  tooltip = null
  legend = null
  legendTarget = null

  // dimensions
  width = 0
  height = 0
  isFullWidth = false
  isFullHeight = false

  // margins
  margin = { top: 10, left: 60, right: 20, bottom: 40 }
  buffer = 10

  // data type flags
  isSingleObject = false
  isArrayOfObjects = false
  isArrayOfArrays = false
  isNestedArrayOfArrays = false
  isNestedArrayOfObjects = false

  // brush
  brush = false
  idleDelay = 350
  idleTimeout = null

  constructor ({
    data,
    target,
    markers,
    baselines,
    xAccessor = 'date',
    yAccessor = 'value',
    margin,
    buffer,
    width,
    height,
    color,
    colors,
    xScale,
    yScale,
    xAxis,
    yAxis,
    showTooltip,
    tooltipFunction,
    legend,
    legendTarget,
    brush,
    ...custom
  }) {
    // set parameters
    this.data = data
    this.target = target
    this.markers = markers ?? this.markers
    this.baselines = baselines ?? this.baselines
    this.legend = legend ?? this.legend
    this.legendTarget = legendTarget ?? this.legendTarget
    this.brush = brush ?? this.brush
    this.xAxisParams = xAxis ?? this.xAxisParams
    this.yAxisParams = yAxis ?? this.yAxisParams
    this.showTooltip = showTooltip ?? this.showTooltip
    this.tooltipFunction = tooltipFunction ?? this.tooltipFunction

    // convert string accessors to functions if necessary
    this.xAccessor = makeAccessorFunction(xAccessor)
    this.yAccessor = makeAccessorFunction(yAccessor)
    this.margin = margin ?? this.margin
    this.buffer = buffer ?? this.buffer

    // set unique id for chart
    this.id = randomId()

    // compute dimensions
    this.width = getWidth(this.isFullWidth, width, this.target)
    this.height = getHeight(this.isFullHeight, height, this.target)

    // normalize color and colors arguments
    this.colors = color ? [color] : colors ? [colors] : constants.defaultColors

    this.setDataTypeFlags()

    // attach base elements to svg
    this.mountSvg()

    // set up scales
    this.xScale = new Scale({ range: [0, this.innerWidth], ...xScale })
    this.yScale = new Scale({ range: [this.innerHeight, 0], ...yScale })

    // normalize data if necessary
    this.normalizeData()

    // compute domains and set them
    const { x, y } = this.computeDomains(custom)
    this.xDomain = x
    this.yDomain = y
    this.xScale.domain = x
    this.yScale.domain = y

    this.abstractRedraw()
  }

  /**
   * Draw the abstract chart.
   * @returns {void}
   */
  abstractRedraw () {
    // clear
    this.content.selectAll('*').remove()

    // set up axes if not disabled
    this.mountXAxis(this.xAxisParams)
    this.mountYAxis(this.yAxisParams)

    // pre-attach tooltip text container
    this.mountTooltip(this.showTooltip, this.tooltipFunction)

    // set up main container
    this.mountContainer()
  }

  /**
   * Draw the actual chart.
   * This is meant to be overridden by chart implementations.
   * @returns {void}
   */
  redraw () {}

  mountBrush (whichBrush) {
    if (!whichBrush) return
    const brush = typeof whichBrush === 'string'
      ? whichBrush === 'x' ? brushX() : brushY()
      : d3brush()
    brush.on('end', () => {
      // compute domains and re-draw
      var s = event.selection
      if (s === null) {
        if (!this.idleTimeout) {
          this.idleTimeout = setTimeout(() => { this.idleTimeout = null }, 350)
          return
        }

        // set original domains
        this.xScale.domain = this.xDomain
        this.yScale.domain = this.yDomain
      } else {
        if (this.brush === 'x') {
          this.xScale.domain = [s[0], s[1]].map(this.xScale.scaleObject.invert)
        } else if (this.brush === 'y') {
          this.yScale.domain = [s[0], s[1]].map(this.yScale.scaleObject.invert)
        } else {
          this.xScale.domain = [s[0][0], s[1][0]].map(this.xScale.scaleObject.invert)
          this.yScale.domain = [s[1][1], s[0][1]].map(this.yScale.scaleObject.invert)
        }
        this.content.select('.brush').call(brush.move, null)
      }

      // re-draw abstract elements
      this.abstractRedraw()

      // re-draw specific chart
      this.redraw()
    })
    this.container.append('g')
      .classed('brush', true)
      .call(brush)
  }

  /**
   * Mount a new legend if necessary
   * @param {String} symbolType symbol type (circle, square, line)
   * @returns {void}
   */
  mountLegend (symbolType) {
    if (!this.legend || !this.legend.length || !this.legendTarget) return
    const legend = new Legend({
      legend: this.legend,
      colorScheme: this.colors,
      symbolType
    })
    legend.mountTo(this.legendTarget)
  }

  /**
   * Mount new x axis.
   *
   * @param {Object} [xAxis] object that can be used to overwrite parameters of the auto-generated x {@link Axis}.
   * @returns {void}
   */
  mountXAxis (xAxis) {
    if (typeof xAxis?.show !== 'undefined' && !xAxis.show) return
    this.xAxis = new Axis({
      scale: this.xScale,
      orientation: 'bottom',
      top: this.bottom,
      left: this.left,
      height: this.innerHeight,
      buffer: this.buffer,
      ...xAxis
    })
    if (!xAxis?.tickFormat) this.computeXAxisType()

    // attach axis
    if (this.xAxis) this.xAxis.mountTo(this.content)
  }

  /**
   * Mount new y axis.
   *
   * @param {Object} [yAxis] object that can be used to overwrite parameters of the auto-generated y {@link Axis}.
   * @returns {void}
   */
  mountYAxis (yAxis) {
    if (typeof yAxis?.show !== 'undefined' && !Axis.show) return
    this.yAxis = new Axis({
      scale: this.yScale,
      orientation: 'left',
      top: this.top,
      left: this.left,
      height: this.innerWidth,
      buffer: this.buffer,
      ...yAxis
    })
    if (!yAxis?.tickFormat) this.computeYAxisType()
    if (this.yAxis) this.yAxis.mountTo(this.content)
  }

  /**
   * Mount a new tooltip if necessary.
   *
   * @param {Boolean} [showTooltip] whether or not to show a tooltip.
   * @param {Function} [tooltipFunction] function that receives a data object and returns the string displayed as tooltip.
   * @returns {void}
   */
  mountTooltip (showTooltip, tooltipFunction) {
    if (typeof showTooltip !== 'undefined' && !showTooltip) return
    this.tooltip = new Tooltip({
      top: this.buffer,
      left: this.width - 2 * this.buffer,
      xAccessor: this.xAccessor,
      yAccessor: this.yAccessor,
      textFunction: tooltipFunction,
      colors: this.colors,
      legend: this.legend
    })
    this.tooltip.mountTo(this.content)
  }

  /**
   * Mount the main container.
   * @returns {void}
   */
  mountContainer () {
    this.container = this.content
      .append('g')
      .attr('transform', `translate(${this.left},${this.top})`)
      .attr('clip-path', `url(#mg-plot-window-${this.id})`)
      .append('g')
      .attr('transform', `translate(${this.buffer},${this.buffer})`)
    this.container
      .append('rect')
      .attr('x', 0)
      .attr('y', 0)
      .attr('opacity', 0)
      .attr('pointer-events', 'all')
      .attr('width', max(this.xScale.range))
      .attr('height', max(this.yScale.range))
  }

  /**
   * This method is called by the abstract chart constructor.
   * In order to simplify parsing of passed data, set flags specifying what types of data we're dealing with.
   * @returns {void}
   */
  setDataTypeFlags () {
    // case 1: data is just one object, e.g. for bar chart
    if (!Array.isArray(this.data)) {
      this.isSingleObject = true
      return
    }

    // case 2: data is array of objects
    if (!isArrayOfArrays(this.data)) {
      this.isArrayOfObjects = true
      return
    }

    // case 3: data is at least array of arrays
    this.isArrayOfArrays = true

    // case 4: nested array of objects
    this.isNestedArrayOfObjects = this.data.every(da => isArrayOfObjectsOrEmpty(da))

    // case 5: nested array of arrays
    this.isNestedArrayOfArrays = this.data.every(da => isArrayOfArrays(da))
  }

  /**
   * This method is called by the abstract chart constructor.
   * Append the local svg node to the specified target, if necessary.
   * Return existing svg node if it's already present.
   * @returns {void}
   */
  mountSvg () {
    const svg = select(this.target).select('svg')
    this.svg = (!svg || svg.empty())
      ? select(this.target)
        .append('svg')
        .classed('mg-graph', true)
        .attr('width', this.width)
        .attr('height', this.height)
      : svg

    // prepare clip path
    this.svg.selectAll('.mg-clip-path').remove()
    this.svg.append('defs')
      .attr('class', 'mg-clip-path')
      .append('clipPath')
      .attr('id', `mg-plot-window-${this.id}`)
      .append('svg:rect')
      .attr('width', this.width - this.margin.left - this.margin.right)
      .attr('height', this.height - this.margin.top - this.margin.bottom)

    // set viewbox
    this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`)
    if (this.isFullWidth || this.isFullHeight) {
      this.svg.attr('preserveAspectRatio', 'xMinYMin meet')
    }

    // append content
    this.content = this.svg
      .append('g')
      .classed('mg-content', true)
  }

  /**
   * If needed, charts can implement data normalizations, which are applied when instantiating a new chart.
   * @returns {void}
   */
  normalizeData () {}

  /**
   * Usually, the domains of the chart's scales depend on the chart type and the passed data, so this should usually be overwritten by chart implementations.
   * @param {Object} params object of custom parameters for the specific chart type
   * @returns {Object} specifying the domain for x and y axis as separate properties.
   */
  computeDomains (params) {
    const flatData = this.data.flat()
    const x = extent(flatData, this.xAccessor)
    const y = extent(flatData, this.yAccessor)
    return { x, y }
  }

  /**
   * Meant to be overwritten by chart implementations.
   * Set tick format of the x axis.
   * @returns {void}
   */
  computeXAxisType () {}

  /**
   * Meant to be overwritten by chart implementations.
   * Set tick format of the y axis.
   * @returns {void}
   */
  computeYAxisType () {}

  generatePoint (args) {
    return new Point({
      xAccessor: this.xAccessor,
      yAccessor: this.yAccessor,
      xScale: this.xScale,
      yScale: this.yScale,
      ...args
    })
  }

  get top () { return this.margin.top }
  get left () { return this.margin.left }
  get bottom () { return this.height - this.margin.bottom }

  // returns the pixel location of the respective side of the plot area.
  get plotTop () { return this.top + this.buffer }
  get plotLeft () { return this.left + this.buffer }

  get innerWidth () { return this.width - this.margin.left - this.margin.right - 2 * this.buffer }
  get innerHeight () { return this.height - this.margin.top - this.margin.bottom - 2 * this.buffer }
}