mozilla/metrics-graphics

View on GitHub
lib/src/components/axis.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import { axisTop, axisLeft, axisRight, axisBottom, format, timeFormat } from 'd3'
import constants from '../misc/constants'
import { GD3Selection, LineD3Selection, TextD3Selection, TextFunction } from '../misc/typings'
import Scale from './scale'

const DEFAULT_VERTICAL_OFFSET = 35
const DEFAULT_HORIZONTAL_OFFSET = 45

type NumberFormatFunction = (x: number) => string
type DateFormatFunction = (x: Date) => string
type FormatFunction = NumberFormatFunction | DateFormatFunction

export enum AxisOrientation {
  TOP = 'top',
  BOTTOM = 'bottom',
  RIGHT = 'right',
  LEFT = 'left'
}

export enum AxisFormat {
  DATE = 'date',
  NUMBER = 'number',
  PERCENTAGE = 'percentage'
}

export interface IAxis {
  /** scale of the axis */
  scale: Scale

  /** buffer used by the chart, necessary to compute margins */
  buffer: number

  /** whether or not to show the axis */
  show?: boolean

  /** orientation of the axis */
  orientation?: AxisOrientation

  /** optional label to place beside the axis */
  label?: string

  /** offset between label and axis */
  labelOffset?: number

  /** translation from the top of the chart's box to render the axis */
  top?: number

  /** translation from the left of the chart's to render the axis */
  left?: number

  /** can be 1) a function to format a given tick or a specifier, or 2) one of the available standard formatting types (date, number, percentage) or a string for d3-format */
  tickFormat?: TextFunction | AxisFormat | string

  /** number of ticks to render, defaults to 3 for vertical and 6 for horizontal axes */
  tickCount?: number

  /** whether or not to render a compact version of the axis (clamps the main axis line at the outermost ticks) */
  compact?: boolean

  /** prefix for tick labels */
  prefix?: string

  /** suffix for tick labels */
  suffix?: string

  /** overwrite d3's default tick lengths */
  tickLength?: number

  /** draw extended ticks into the graph (used to make a grid) */
  extendedTicks?: boolean

  /** if extended ticks are used, this parameter specifies the inner length of ticks */
  height?: number
}

export default class Axis {
  label = ''
  labelOffset = 0
  top = 0
  left = 0
  scale: Scale
  orientation = AxisOrientation.BOTTOM
  axisObject: any
  compact = false
  extendedTicks = false
  buffer = 0
  height = 0
  prefix = ''
  suffix = ''

  constructor({
    orientation,
    label,
    labelOffset,
    top,
    left,
    height,
    scale,
    tickFormat,
    tickCount,
    compact,
    buffer,
    prefix,
    suffix,
    tickLength,
    extendedTicks
  }: IAxis) {
    this.scale = scale
    this.label = label ?? this.label
    this.buffer = buffer ?? this.buffer
    this.top = top ?? this.top
    this.left = left ?? this.left
    this.height = height ?? this.height
    this.orientation = orientation ?? this.orientation
    this.compact = compact ?? this.compact
    this.prefix = prefix ?? this.prefix
    this.suffix = suffix ?? this.suffix
    if (typeof tickLength !== 'undefined') this.tickLength = tickLength
    this.extendedTicks = extendedTicks ?? this.extendedTicks
    this.setLabelOffset(labelOffset)

    this.setupAxisObject()

    // set or compute tickFormat
    if (tickFormat) this.tickFormat = tickFormat
    this.tickCount = tickCount ?? (this.isVertical ? 3 : 6)
  }

  /**
   * Set the label offset.
   *
   * @param labelOffset offset of the label.
   */
  setLabelOffset(labelOffset?: number): void {
    this.labelOffset =
      typeof labelOffset !== 'undefined'
        ? labelOffset
        : this.isVertical
        ? DEFAULT_HORIZONTAL_OFFSET
        : DEFAULT_VERTICAL_OFFSET
  }

  /**
   * Set up the main axis object.
   */
  setupAxisObject(): void {
    switch (this.orientation) {
      case constants.axisOrientation.top:
        this.axisObject = axisTop(this.scale.scaleObject)
        break
      case constants.axisOrientation.left:
        this.axisObject = axisLeft(this.scale.scaleObject)
        break
      case constants.axisOrientation.right:
        this.axisObject = axisRight(this.scale.scaleObject)
        break
      default:
        this.axisObject = axisBottom(this.scale.scaleObject)
        break
    }
  }

  /**
   * Get the domain object call function.
   * @returns that mounts the domain when called.
   */
  domainObject() {
    return (g: GD3Selection): LineD3Selection =>
      g
        .append('line')
        .classed('domain', true)
        .attr('x1', this.isVertical ? 0.5 : this.compact ? this.buffer : 0)
        .attr('x2', this.isVertical ? 0.5 : this.compact ? this.scale.range[1] : this.scale.range[1] + 2 * this.buffer)
        .attr('y1', this.isVertical ? (this.compact ? this.top + 0.5 : 0.5) : 0)
        .attr(
          'y2',
          this.isVertical ? (this.compact ? this.scale.range[0] + 0.5 : this.scale.range[0] + 2 * this.buffer + 0.5) : 0
        )
  }

  /**
   * Get the label object call function.
   * @returns {Function} that mounts the label when called.
   */
  labelObject(): (node: GD3Selection) => TextD3Selection {
    const value = Math.abs(this.scale.range[0] - this.scale.range[1]) / 2
    const xValue = this.isVertical ? -this.labelOffset : value
    const yValue = this.isVertical ? value : this.labelOffset
    return (g) =>
      g
        .append('text')
        .attr('x', xValue)
        .attr('y', yValue)
        .attr('text-anchor', 'middle')
        .classed('label', true)
        .attr('transform', this.isVertical ? `rotate(${-90} ${xValue},${yValue})` : '')
        .text(this.label)
  }

  get isVertical(): boolean {
    return [constants.axisOrientation.left, constants.axisOrientation.right].includes(this.orientation)
  }

  get innerLeft(): number {
    return this.isVertical ? 0 : this.buffer
  }

  get innerTop(): number {
    return this.isVertical ? this.buffer : 0
  }

  get tickAttribute(): string {
    return this.isVertical ? 'x1' : 'y1'
  }

  get extendedTickLength(): number {
    const factor = this.isVertical ? 1 : -1
    return factor * (this.height + 2 * this.buffer)
  }

  /**
   * Mount the axis to the given d3 node.
   * @param svg d3 node.
   */
  mountTo(svg: GD3Selection): void {
    // set up axis container
    const axisContainer = svg
      .append('g')
      .attr('transform', `translate(${this.left},${this.top})`)
      .classed('mg-axis', true)

    // if no extended ticks are used, draw the domain line
    if (!this.extendedTicks) axisContainer.call(this.domainObject())

    // mount axis but remove default-generated domain
    axisContainer
      .append('g')
      .attr('transform', `translate(${this.innerLeft},${this.innerTop})`)
      .call(this.axisObject)
      .call((g) => g.select('.domain').remove())

    // if necessary, make ticks longer
    if (this.extendedTicks) {
      axisContainer.call((g) =>
        g.selectAll('.tick line').attr(this.tickAttribute, this.extendedTickLength).attr('opacity', 0.3)
      )
    }

    // if necessary, add label
    if (this.label !== '') axisContainer.call(this.labelObject())
  }

  /**
   * Compute the time formatting function based on the time domain.
   * @returns d3 function for formatting time.
   */
  diffToTimeFormat(): FormatFunction {
    const diff = Math.abs(this.scale.domain[1] - this.scale.domain[0]) / 1000

    const millisecondDiff = diff < 1
    const secondDiff = diff < 60
    const dayDiff = diff / (60 * 60) < 24
    const fourDaysDiff = diff / (60 * 60) < 24 * 4
    const manyDaysDiff = diff / (60 * 60 * 24) < 60
    const manyMonthsDiff = diff / (60 * 60 * 24) < 365

    return millisecondDiff
      ? timeFormat('%M:%S.%L')
      : secondDiff
      ? timeFormat('%M:%S')
      : dayDiff
      ? timeFormat('%H:%M')
      : fourDaysDiff || manyDaysDiff || manyMonthsDiff
      ? timeFormat('%b %d')
      : timeFormat('%Y')
  }

  /**
   * Get the d3 number formatting function for an abstract number type.
   *
   * @param formatType abstract format to be converted (number, date, percentage)
   * @returns d3 formatting function for the given abstract number type.
   */
  stringToFormat(formatType: AxisFormat | string): FormatFunction {
    switch (formatType) {
      case constants.axisFormat.number:
        return this.isVertical ? format('~s') : format('')
      case constants.axisFormat.date:
        return this.diffToTimeFormat()
      case constants.axisFormat.percentage:
        return format('.0%')
      default:
        return format(formatType)
    }
  }

  get tickFormat() {
    return this.axisObject.tickFormat()
  }

  set tickFormat(tickFormat: FormatFunction | string) {
    // if tickFormat is a function, apply it directly
    const formatFunction = typeof tickFormat === 'function' ? tickFormat : this.stringToFormat(tickFormat)

    this.axisObject.tickFormat((d: any) => `${this.prefix}${formatFunction(d)}${this.suffix}`)
  }

  get tickCount() {
    return this.axisObject.ticks()
  }

  set tickCount(tickCount: number) {
    this.axisObject.ticks(tickCount)
  }

  get tickLength() {
    return this.axisObject.tickSize()
  }

  set tickLength(length: number) {
    this.axisObject.tickSize(length)
  }
}