lib/datavisualisations/ColumnChart.vue

Summary

Maintainability
Test Coverage
<template>
  <div
    class="column-chart"
    :style="{ '--column-color': columnColor, '--column-highlight-color': columnHighlightColor }"
    :class="{
      'column-chart--has-highlights': dataHasHighlights,
      'column-chart--hover': hover,
      'column-chart--stripped': stripped,
      'column-chart--social-mode': socialMode
    }"
  >
    <svg :width="width" :height="height">
      <g :style="{ transform: `translate(${margin.left}px, ${margin.top}px)` }">
        <g
          v-if="!noXAxis"
          class="column-chart__axis column-chart__axis--x"
          :style="{ transform: `translate(0, ${padded.height}px)` }"
        />
        <g v-if="!noYAxis" class="column-chart__axis column-chart__axis--y" />
      </g>
      <g class="column-chart__columns" :style="{ transform: `translate(${margin.left}px, ${margin.top}px)` }">
        <g
          v-for="(bar, index) in bars"
          :key="index"
          class="column-chart__columns__item"
          :class="{ 'column-chart__columns__item--highlight': highlighted(bar.datum) }"
          :style="{ transform: `translate(${bar.x}px, 0px)` }"
          @click="select(bar)"
          @mouseover="shownTooltip = index"
          @mouseleave="shownTooltip = -1"
        >
          <rect class="column-chart__columns__item__placeholder" :width="bar.width" :height="padded.height" />
          <rect class="column-chart__columns__item__bar" :width="bar.width" :height="bar.height" :y="bar.y" />
        </g>
      </g>
    </svg>
    <div v-if="!noTooltips" class="column-chart__tooltips">
      <div v-for="(bar, index) in bars" :key="index">
        <div class="column-chart__tooltips__item" :style="barTooltipStyle(bar)">
          <transition name="fade">
            <div v-if="shownTooltip === index" class="column-chart__tooltips__item__wrapper">
              <slot name="tooltip" v-bind="bar">
                <h6 class="column-chart__tooltips__item__wrapper__heading mb-0">
                  {{ bar.datum[timeseriesKey] | d3Formatter(xAxisTickFormat) }}
                </h6>
                <div class="column-chart__tooltips__item__wrapper__value">
                  {{ bar.datum[seriesName] | d3Formatter(yAxisTickFormat) }}
                </div>
              </slot>
            </div>
          </transition>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { iteratee, identity, sortBy } from 'lodash'
import * as d3 from 'd3'

import chart from '../mixins/chart'

export default defineComponent({
  name: 'ColumnChart',
  mixins: [chart],
  props: {
    /**
     * Color of each column (uses the CSS variable --column-color by default)
     */
    columnColor: {
      type: String as PropType<string>,
      default: null
    },
    /**
     * Color of each highlighted column (uses the CSS variable --column-color by default)
     */
    columnHighlightColor: {
      type: String as PropType<string>,
      default: null
    },
    /**
     * Enforce the height of the chart (regardless of the width or the social mode)
     */
    fixedHeight: {
      type: Number as PropType<number>,
      default: null
    },
    /**
     * Enforce a width for each column's label
     */
    fixedLabelWidth: {
      type: Number as PropType<number>,
      default: null
    },
    /**
     * Name of the series (to get the value from in the data collection objects)
     */
    seriesName: {
      type: String as PropType<string>,
      default: 'value'
    },
    /**
     * Hide x axis ticks when no enough space
     */
    xAxisTickCollapse: {
      type: Boolean as PropType<boolean> as PropType<boolean>,
      default: false
    },
    /**
     * Function to apply to format x axis ticks
     */
    xAxisTickFormat: {
      type: [Function, String] as PropType<Function | string>,
      default: identity
    },
    /**
     * Definition of x axis ticks
     */
    xAxisTicks: {
      type: Array as PropType<string[] | null>,
      default: null
    },
    /**
     * Function to apply to format y axis ticks
     */
    yAxisTickFormat: {
      type: [Function, String] as PropType<Function | string>,
      default: identity
    },
    /**
     * Definition of y axis ticks
     */
    yAxisTicks: {
      type: [Number, Object] as PropType<number | object>,
      default: 5
    },
    /**
     * Sort columns by one or several keys.
     */
    sortBy: {
      type: [Array, String] as PropType<string | string[]>,
      default: null
    },
    /**
     * Key to use for timeseries
     */
    timeseriesKey: {
      type: String as PropType<string>,
      default: 'date'
    },
    /**
     * Set max value instead of extracting it from the data.
     */
    maxValue: {
      type: Number as PropType<number>,
      default: null
    },
    /**
     * Hide bar tooltips
     */
    noTooltips: {
      type: Boolean as PropType<boolean>
    },
    /**
     * Hide x axis
     */
    noXAxis: {
      type: Boolean as PropType<boolean>
    },
    /**
     * Hide y axis
     */
    noYAxis: {
      type: Boolean as PropType<boolean>
    },
    /**
     * Bar padding as a portion of each bar width
     */
    barPadding: {
      type: Number as PropType<number>,
      default: 0.35
    },
    /**
     * Bar margin in pixel
     */
    barMargin: {
      type: Number as PropType<number>,
      default: 0
    },
    /**
     * A list of highlighted key
     */
    highlights: {
      type: Array as PropType<string[]>,
      default: () => []
    },
    /**
     * Show a "placeholder" behind every bar
     */
    stripped: {
      type: Boolean as PropType<boolean>
    },
    /**
     * Show a "placeholder" behind every bar on hover
     */
    hover: {
      type: Boolean as PropType<boolean>
    }
  },
  data() {
    return {
      width: 0,
      height: 0,
      shownTooltip: -1
    }
  },
  computed: {
    sortedData(): any[] {
      if (!this.loadedData) {
        return []
      }
      return !this.sortBy ? this.loadedData : sortBy(this.sortedData, this.sortBy)
    },
    labelWidth(): number {
      if (this.fixedLabelWidth) {
        return this.fixedLabelWidth
      }
      const selector = '.column-chart__axis--y .tick text'
      const defaultWidth = 100
      return this.elementsMaxBBox({ selector, defaultWidth }).width
    },
    labelHeight(): number {
      if (this.noYAxis) {
        return 0
      }
      const selector = '.column-chart__axis--y .tick text'
      const defaultHeight = 10
      return this.elementsMaxBBox({ selector, defaultHeight }).height
    },
    bucketHeight(): number {
      if (this.noXAxis) {
        return 0
      }
      const selector = '.column-chart__axis--x .tick text'
      const defaultHeight = 10
      return this.elementsMaxBBox({ selector, defaultHeight }).height
    },
    bucketWidth(): number {
      const selector = '.column-chart__axis--x .tick text'
      const defaultWidth = 100
      return this.elementsMaxBBox({ selector, defaultWidth }).width
    },
    margin(): { left: number; right: number; top: number; bottom: number } {
      return {
        left: this.noYAxis ? 0 : this.labelWidth + 10,
        right: 0,
        top: this.labelHeight / 2,
        bottom: this.noXAxis ? 0 : this.bucketHeight + 10
      }
    },
    padded(): { width: number; height: number } {
      const width = this.width - this.margin.left - this.margin.right
      const height = this.height - this.margin.top - this.margin.bottom
      return { width, height }
    },
    scaleX(): d3.ScaleBand<string> {
      return d3
        .scaleBand()
        .domain(this.sortedData.map(iteratee(this.timeseriesKey)))
        .range([0, this.padded.width])
        .padding(this.barPadding)
    },
    scaleY(): d3.ScaleLinear<number, number> {
      const maxValue = this.maxValue ?? d3.max(this.sortedData, iteratee(this.seriesName))
      return d3.scaleLinear().domain([0, maxValue]).range([this.padded.height, 0])
    },
    bars(): any[] {
      return this.sortedData.map((datum: any) => {
        return {
          datum,
          width: Math.max(1, Math.abs(this.scaleX.bandwidth()) - this.barMargin),
          height: Math.abs(this.padded.height - this.scaleY(datum[this.seriesName])),
          x: (this.scaleX(datum[this.timeseriesKey]) ?? 0) + this.barMargin / 2,
          y: this.scaleY(datum[this.seriesName]) ?? 0
        }
      })
    },
    xAxisHiddenTicks(): number {
      if (!this.xAxisTickCollapse) {
        return 0
      }

      const hiddenTicks = d3.range(1, this.sortedData.length).find((mod) => {
        const bucketWidth = this.bucketWidth * 1.5
        return this.width / (bucketWidth / mod) >= this.sortedData.length
      })

      return hiddenTicks ?? this.sortedData.length
    },
    xAxisTickValues(): string[] {
      // Etheir use the explicit `xAxisTicks` prop or use the data
      const ticks = this.xAxisTicks ?? this.sortedData.map(iteratee(this.timeseriesKey))
      // Then filter out ticks according to `this.xAxisHiddenTicks`
      return ticks.map((tick, i) => {
        return (i + 1) % this.xAxisHiddenTicks ? null : tick
      })
    },
    xAxis(): d3.Axis<string> {
      return d3
        .axisBottom(this.scaleX)
        .tickFormat((d) => this.$options.filters?.d3Formatter(d, this.xAxisTickFormat))
        .tickValues(this.xAxisTickValues)
    },
    yAxis(): d3.Axis<d3.NumberValue> {
      return d3
        .axisLeft(this.scaleY)
        .tickFormat((d) => this.$options.filters?.d3Formatter(d, this.yAxisTickFormat))
        .ticks(this.yAxisTicks)
    }
  },
  watch: {
    width() {
      this.setup()
    },
    fixedHeight() {
      this.setSizes()
    },
    socialMode() {
      this.setup()
    },
    loadedData() {
      this.setup()
    },
    mounted() {
      this.setup()
    }
  },
  mounted() {
    this.$on('resized', this.setSizes)
    this.setSizes()
  },
  methods: {
    setup() {
      this.update()
    },
    setSizes() {
      this.width = (this.$el as HTMLElement)?.offsetWidth ?? 0
      this.height = this.fixedHeight !== null ? this.fixedHeight : this.width * this.baseHeightRatio
      this.update()
    },
    select({ datum }: { datum: any }) {
      /**
       * Fired when a column is selected
       * @event click
       * @param Mixed New step value.
       */
      this.$emit('select', datum)
    },
    update() {
      if (!this.$el) {
        return
      }

      d3.select(this.$el)
        .select('.column-chart__axis--x')
        .call(this.xAxis as any)
        .select('.domain')
        .remove()

      d3.select(this.$el)
        .select('.column-chart__axis--y')
        .call(this.yAxis as any)
        .selectAll('.tick line')
        .attr('x2', this.padded.width)
    },
    barTooltipStyle(bar: object) {
      const top = `${bar.y + this.margin.top}px`
      const left = `${bar.x + bar.width / 2 + this.margin.left}px`
      return { top, left }
    },
    highlighted(datum: any): boolean {
      return datum.highlight || this.highlights.includes(datum[this.timeseriesKey])
    }
  }
})
</script>

<style lang="scss">
@import '../styles/lib';

.column-chart {
  --highlight-opacity: 0.7;
  --placeholder-opacity: 0.1;

  position: relative;

  &--has-highlights &__columns__item:not(&__columns__item--highlight):not(:hover) {
    opacity: var(--highlight-opacity);
    filter: grayscale(30%);
  }

  text {
    font-family: $font-family-base;
    font-size: $font-size-sm;
    fill: currentColor;
  }

  &__columns__item {
    fill: var(--column-color, var(--dark, $dark));

    &--highlight {
      fill: var(--column-highlight-color, var(--primary, $primary));
    }

    &__placeholder {
      opacity: 0;

      .column-chart--stripped &,
      .column-chart--hover .column-chart__columns__item:hover & {
        opacity: var(--placeholder-opacity);
      }
    }
  }

  &__axis {
    .domain {
      display: none;
    }

    .tick line {
      stroke: $border-color;
    }

    &--x .tick line {
      display: none;
    }
  }

  &__tooltips {
    pointer-events: none;
    position: absolute;
    top: 0;
    left: 0;

    &__item {
      display: inline-flex;
      text-align: center;
      flex-direction: row;
      align-items: flex-end;
      justify-content: flex-start;
      position: absolute;
      transform: translate(-50%, -100%);
      margin-top: -0.5 * $tooltip-arrow-width;

      &__wrapper {
        max-width: $tooltip-max-width;
        background: rgba($tooltip-bg, $tooltip-opacity);
        border-radius: $tooltip-border-radius;
        color: $tooltip-color;
        margin: 0;
        padding: $tooltip-padding-y $tooltip-padding-x;

        &.fade-enter-active,
        &.fade-leave-active {
          transition: $transition-fade;
        }

        &.fade-enter,
        &.fade-leave-to {
          opacity: 0;
        }

        &:after {
          content: '';
          border: ($tooltip-arrow-width * 0.5) solid transparent;
          border-top-color: rgba($tooltip-bg, $tooltip-opacity);
          transform: translateX(-50%);
          position: absolute;
          left: 50%;
          top: 100%;
        }
      }
    }
  }
}
</style>