lib/datavisualisations/StackedColumnChart.vue

Summary

Maintainability
Test Coverage
<template>
  <div
    :style="{ height: `${height}px` }"
    class="stacked-column-chart d-flex flex-column"
    :class="{
      'stacked-column-chart--social-mode': socialMode,
      'stacked-column-chart--has-highlights': hasHighlights || hasColumnHighlights,
      'stacked-column-chart--no-direct-labeling': noDirectLabeling
    }"
  >
    <ul v-if="!hideLegend" class="stacked-column-chart__legend list-inline">
      <li
        v-for="key in discoveredKeys"
        :key="key"
        class="stacked-column-chart__legend__item list-inline-item d-inline-flex"
        :class="{
          'stacked-column-chart__legend__item--highlighted': isHighlighted(key)
        }"
        @mouseover="delayHighlight(key)"
        @mouseleave="restoreHighlights()"
      >
        <span class="stacked-column-chart__legend__item__box" :style="{ 'background-color': colorScale(key) }" />
        {{ groupName(key) }}
      </li>
    </ul>
    <div class="d-flex flex-grow-1 position-relative overflow-hidden">
      <svg
        v-show="noDirectLabeling"
        :width="width + 'px'"
        :height="height + 'px'"
        class="stacked-column-chart__left-axis"
      >
        <g class="stacked-column-chart__left-axis__canvas" :transform="`translate(${width}, 0)`" />
      </svg>
      <div class="stacked-column-chart__groups d-flex flex-grow-1" :style="paddedStyle">
        <div
          v-for="(datum, i) in sortedData"
          :key="i"
          class="stacked-column-chart__groups__item flex-grow-1 d-flex flex-column text-center"
        >
          <div
            class="stacked-column-chart__groups__item__bars flex-grow-1 d-flex flex-column-reverse px-1 justify-content-start align-items-center"
          >
            <div
              v-for="(key, j) in discoveredKeys"
              :key="j"
              v-b-tooltip.html="{ delay: barTooltipDelay, disabled: noTooltips, title: barTitle(i, key) }"
              :style="barStyle(i, key)"
              class="stacked-column-chart__groups__item__bars__item"
              :class="{
                [`stacked-column-chart__groups__item__bars__item--${key}`]: true,
                [`stacked-column-chart__groups__item__bars__item--${j}n`]: true,
                'stacked-column-chart__groups__item__bars__item--hidden': isHidden(i, key),
                'stacked-column-chart__groups__item__bars__item--highlighted':
                  isHighlighted(key) || isColumnHighlighted(i),
                'stacked-column-chart__groups__item__bars__item--value-overflow': hasValueOverflow(i, key),
                'stacked-column-chart__groups__item__bars__item--value-pushed': hasValuePushed(i, key),
                'stacked-column-chart__groups__item__bars__item--value-hidden': hasValueHidden(i, key)
              }"
              @mouseover="delayHighlight(key)"
              @mouseleave="restoreHighlights()"
            >
              <div v-show="!noDirectLabeling" class="stacked-column-chart__groups__item__bars__item__value">
                {{ datum[key] | d3Formatter(yAxisTickFormat) }}
              </div>
            </div>
          </div>
          <div class="stacked-column-chart__groups__item__label small py-2">
            {{ datum[labelField] | d3Formatter(xAxisTickFormat) }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { VBTooltip } from 'bootstrap-vue/esm/directives/tooltip/tooltip'
import * as d3 from 'd3'
import keys from 'lodash/keys'
import find from 'lodash/find'
import get from 'lodash/get'
import identity from 'lodash/identity'
import sortBy from 'lodash/sortBy'
import without from 'lodash/without'
import ResizeObserver from 'resize-observer-polyfill'

import chart from '../mixins/chart'

export default {
  name: 'StackedColumnChart',
  directives: {
    'b-tooltip': VBTooltip
  },
  mixins: [chart],
  props: {
    /**
     * Field of each object containing data (for each group)
     */
    keys: {
      type: Array,
      default: () => []
    },
    /**
     * Group name to display in the legend
     */
    groups: {
      type: Array,
      default: () => []
    },
    /**
     * Colors of each bar group
     */
    barColors: {
      type: Array,
      default: () => []
    },
    /**
     * Max with of each bar.
     */
    barMaxWidth: {
      type: String,
      default: '100%'
    },
    /**
     * Hide bars that have no values.
     */
    hideEmptyValues: {
      type: Boolean
    },
    /**
     * Hide the legend.
     */
    hideLegend: {
      type: Boolean
    },
    /**
     * Enforce the height of the chart (regardless of the width or number of row)
     */
    fixedHeight: {
      type: Number,
      default: null
    },
    /**
     * Function to apply to format x axis ticks
     */
    xAxisTickFormat: {
      type: [Function, String],
      default: identity
    },
    /**
     * Function to apply to format y axis ticks (bars value). It can be a
     * function returning the formatted value or a d3's formatter string.
     */
    yAxisTickFormat: {
      type: [Function, String],
      default: identity
    },
    /**
     * Padding on y axis ticks
     */
    yAxisTickPadding: {
      type: Number,
      default: 10
    },
    /**
     * Field containing the label for each column
     */
    labelField: {
      type: String,
      default: 'date'
    },
    /**
     * Sort groups by one or several keys.
     */
    sortBy: {
      type: [Array, String],
      default: null
    },
    /**
     * Column height is relative to each group's total
     */
    relative: {
      type: Boolean,
      default: false
    },
    /**
     * A list of highlighted groups
     */
    highlights: {
      type: Array,
      default: () => []
    },
    /**
     * Delay to apply when set the first highlight
     */
    highlightDelay: {
      type: Number,
      default: 400
    },
    /**
     * A list of entire column to highlight
     */
    columnHighlights: {
      type: Array,
      default: () => []
    },
    /**
     * Delay to apply when restoring hightlights to initial state
     */
    restoreHighlightDelay: {
      type: Number,
      default: 50
    },
    /**
     * Deactivate direct labeling on bars
     */
    noDirectLabeling: {
      type: Boolean
    },
    /**
     * Set max value instead of extracting it from the data.
     */
    maxValue: {
      type: Number,
      default: null
    },
    /**
     * Function to define tooltip content.
     */
    tooltipDisplay: {
      type: Function,
      default: ({ formattedKey, formattedValue }) => {
        return `<h6 class="mb-0">${formattedKey}</h6><div>${formattedValue}</div>`
      }
    },
    /**
     * Hide bar tooltips
     */
    noTooltips: {
      type: Boolean
    }
  },
  data() {
    return {
      width: 0,
      height: 0,
      leftAxisHeight: 0,
      highlightedKeys: this.highlights,
      highlightTimeout: null
    }
  },
  resizeObserver: null,
  computed: {
    sortedData() {
      if (!this.loadedData) {
        return []
      }
      return !this.sortBy ? this.loadedData : sortBy(this.loadedData, this.sortBy)
    },
    discoveredKeys() {
      if (this.keys.length) {
        return this.keys
      }
      return without(keys(this.sortedData[0] || {}), this.labelField)
    },
    colorScale() {
      return d3.scaleOrdinal().domain(this.discoveredKeys).range(this.barColors)
    },
    maxRowValue() {
      return (
        this.maxValue ||
        d3.max(this.loadedData || [], (datum, i) => {
          return this.totalRowValue(i)
        })
      )
    },
    hasHighlights() {
      return !!this.highlightedKeys.length
    },
    hasColumnHighlights() {
      return !!this.columnHighlights.length
    },
    leftScale() {
      return d3.scaleLinear().domain([0, this.maxRowValue]).range([this.leftAxisHeight, 0])
    },
    leftAxis: {
      cache: false,
      get() {
        return d3
          .axisLeft(this.leftScale)
          .tickFormat((d) => this.$options.filters.d3Formatter(d, this.yAxisTickFormat))
          .tickSize(this.width - this.leftAxisLabelsWidth)
          .tickPadding(this.yAxisTickPadding)
      }
    },
    leftAxisLabelsWidth: {
      cache: false,
      get() {
        const selector = '.stacked-column-chart__left-axis__canvas .tick text'
        const defaultWidth = 0
        return this.elementsMaxBBox({ selector, defaultWidth }).width + this.yAxisTickPadding
      }
    },
    leftAxisCanvas() {
      return d3.select(this.$el).select('.stacked-column-chart__left-axis__canvas')
    },
    paddedStyle() {
      return {
        marginLeft: this.noDirectLabeling ? `${this.leftAxisLabelsWidth + this.yAxisTickPadding}px` : 0
      }
    },
    barTooltipDelay() {
      return this.hasHighlights ? 0 : this.highlightDelay
    }
  },
  watch: {
    socialMode() {
      this.setup()
    },
    loadedData() {
      this.setup()
    },
    leftAxisLabelsWidth() {
      this.setup()
    },
    leftAxisHeight() {
      this.setup()
    },
    fixedHeight() {
      this.setup()
    },
    highlights() {
      this.highlightedKeys = this.highlights
    }
  },
  async mounted() {
    this.$options.resizeObserver = new ResizeObserver(this.setup)
    await this.$nextTick()
    this.$options.resizeObserver?.observe(this.$el)
  },
  beforeDestroy() {
    this.$options.resizeObserver?.unobserve(this.$el)
    this.$options.resizeObserver = null
  },
  methods: {
    setSizes() {
      this.width = this.$el.offsetWidth
      this.height = this.fixedHeight !== null ? this.fixedHeight : this.width * this.baseHeightRatio
    },
    async setup() {
      this.setSizes()
      await this.$nextTick()
      // This must be set after the column have been rendered
      this.leftAxisHeight = this.$el.querySelector('.stacked-column-chart__groups__item__bars').offsetHeight
      this.leftAxisCanvas.call(this.leftAxis)
    },
    groupName(key) {
      const index = this.discoveredKeys.indexOf(key)
      return this.groups[index] || key
    },
    highlight(key) {
      this.highlightedKeys = [key]
    },
    restoreHighlights() {
      clearTimeout(this.highlightTimeout)
      const delay = this.restoreHighlightDelay
      // Delay the restoration so it can be cancelled by a new highlight
      this.highlightTimeout = setTimeout(() => (this.highlightedKeys = this.highlights), delay)
    },
    delayHighlight(key) {
      clearTimeout(this.highlightTimeout)
      // Reduce the delay to zero if there is already an highlighted key
      const isDelayed = !this.hasHighlights
      const delay = isDelayed ? this.highlightDelay : 0
      this.highlightTimeout = setTimeout(() => this.highlight(key), delay)
    },
    isHighlighted(key) {
      return this.highlightedKeys.indexOf(key) > -1
    },
    isColumnHighlighted(i) {
      const column = get(this.sortedData, [i, this.labelField], null)
      return this.columnHighlights.includes(column) && !this.highlightedKeys.length
    },
    totalRowValue(i) {
      return d3.sum(this.discoveredKeys, (key) => {
        return this.sortedData[i][key]
      })
    },
    barStyle(i, key) {
      const value = this.sortedData[i][key]
      const totalWith = this.relative ? this.totalRowValue(i) : this.maxRowValue
      const height = `${100 * (value / totalWith)}%`
      const backgroundColor = this.colorScale(key)
      const maxWidth = this.barMaxWidth
      return { maxWidth, height, backgroundColor }
    },
    barTitle(i, key) {
      const value = this.sortedData[i][key]
      const formattedValue = this.$options.filters.d3Formatter(value, this.yAxisTickFormat)
      const formattedKey = this.groupName(key)
      return this.tooltipDisplay({ value, formattedValue, key, formattedKey })
    },
    stackBarAndValue(i) {
      if (!this.mounted) {
        return []
      }
      // Collect sizes first
      const stack = this.discoveredKeys.map((key) => {
        const { bar, row, value } = this.queryBarAndValue(i, key)
        const barEdge = bar.getBoundingClientRect().top + bar.offsetHeight
        const barHeight = bar.offsetHeight
        const rowEdge = row.getBoundingClientRect().top + row.offsetHeight
        const valueHeight = value.offsetHeight
        return { key, barEdge, barHeight, rowEdge, valueHeight }
      })
      // Infer value's display
      return stack.map((desc, index) => {
        desc.overflow = desc.valueHeight >= desc.barHeight
        if (index > 0) {
          const prevDesc = stack[index - 1]
          const bothValuesHeight = desc.valueHeight + prevDesc.valueHeight
          desc.overflow = desc.overflow || (prevDesc.overflow && desc.barHeight < bothValuesHeight)
        }
        desc.pushed = desc.barEdge + desc.valueHeight > desc.rowEdge && desc.overflow
        return desc
      })
    },
    queryBarAndValue(i, key) {
      if (!this.mounted) {
        return {}
      }
      const rowSelector = '.stacked-column-chart__groups__item'
      const row = this.$el.querySelectorAll(rowSelector)[i]
      const barSelector = `.stacked-column-chart__groups__item__bars__item--${key}`
      const bar = row.querySelector(barSelector)
      const valueSelector = '.stacked-column-chart__groups__item__bars__item__value'
      const value = bar.querySelector(valueSelector)
      return { bar, row, value }
    },
    isHidden(i, key) {
      return this.hideEmptyValues && !this.sortedData[i][key]
    },
    hasValueOverflow(i, key) {
      const stack = this.stackBarAndValue(i)
      return find(stack, { key })?.overflow
    },
    hasValuePushed(i, key) {
      const stack = this.stackBarAndValue(i)
      return find(stack, { key })?.pushed
    },
    hasValueHidden(i, key) {
      const keyIndex = this.discoveredKeys.indexOf(key)
      const nextKey = this.discoveredKeys[keyIndex + 1]
      if (!nextKey) {
        return false
      }
      return this.hasValueOverflow(i, key) && this.hasValueOverflow(i, nextKey)
    }
  }
}
</script>

<style lang="scss">
@use 'sass:math';
@import '../styles/lib';

.stacked-column-chart {
  $muted-group-opacity: 0.2;
  $muted-group-filter: grayscale(30%) brightness(10%);
  $muted-group-transition: opacity 0.3s, filter 0.3s;
  $colors: $primary, $info, $warning;
  $quantile: 3;

  @each $start-color in $colors {
    $i: index($colors, $start-color) - 1;
    $end-color: mix($start-color, text-contrast($start-color), 20%);

    @for $j from ($quantile * $i) through ($quantile * $i + $quantile - 1) {
      $amount: ($j % $quantile) * math.div(100%, $quantile);
      --group-color-#{$j}n: #{mix($end-color, $start-color, $amount)};
    }
  }

  &__legend {
    &__item {
      display: inline-flex;
      flex-direction: row;
      align-items: center;
      padding-right: $spacer * 0.5;

      @for $i from 0 through ($quantile * length($colors)) {
        &:nth-child(#{$i + 1}n) &__box {
          background-color: var(--group-color-#{$i}n);
        }
      }

      .stacked-column-chart--has-highlights &:not(&--highlighted) {
        opacity: $muted-group-opacity;
        filter: $muted-group-filter;
      }

      &__box {
        height: 1em;
        width: 1em;
        border-radius: 0.5em;
        display: inline-block;
        margin-right: $spacer * 0.5;
      }
    }
  }

  &__left-axis {
    position: absolute;
    top: 0;
    left: 0;

    path {
      display: none;
    }

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

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

  &__groups {
    &__item {
      &__bars {
        &__item {
          width: 100%;
          position: relative;
          min-height: 1px;

          @for $i from 0 through ($quantile * length($colors)) {
            &--#{$i}n {
              background: var(--group-color-#{$i}n);
            }
          }

          &__value {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            text-align: center;
            white-space: nowrap;
            color: #fff;
          }

          .stacked-column-chart--has-highlights &:not(&--highlighted) {
            opacity: $muted-group-opacity;
            filter: $muted-group-filter;
          }

          .stacked-column-chart--has-highlights &:not(&--highlighted) &__value {
            visibility: hidden;
          }

          .stacked-column-chart:not(.stacked-column-chart--has-highlights) &--value-hidden &__value,
          .stacked-column-chart:not(.stacked-column-chart--has-highlights) &--value-pushed &__value {
            visibility: hidden;
          }

          &--hidden {
            display: none;
          }

          &--value-overflow &__value {
            color: $body-color;
            transform: translateY(-100%);
          }

          &--value-pushed {
            direction: ltr;
          }

          &--value-pushed &__value {
            color: $body-color;
            transform: translateY(100%);
          }
        }
      }
    }
  }
}
</style>