lib/components/ScaleLegend.vue

Summary

Maintainability
Test Coverage
<script lang="ts">
import { isFunction, isString } from 'lodash'
import * as d3 from 'd3'
import * as scaleFunctions from 'd3-scale'
import { defineComponent, PropType } from 'vue'

type ClassListLegend = { 'scale-legend--has-cursor': boolean }
// eslint-disable-next-line no-unused-vars
type ColorScaleFn = (v?: number) => string

type ColorScale = ColorScaleFn | string
// eslint-disable-next-line no-unused-vars
type WidthScaleFn = (x: number) => string

export default defineComponent({
  name: 'ScaleLegend',
  filters: {
    formatNumber: d3.format(',')
  },
  props: {
    width: {
      type: Number,
      default: 150
    },
    height: {
      type: Number,
      default: 16
    },
    cursorValue: {
      type: Number,
      default: null
    },
    max: {
      type: Number,
      default: 100
    },
    min: {
      type: Number,
      default: 0
    },
    colorScale: {
      type: [Function, String] as PropType<ColorScaleFn | string>,
      default: 'scaleLinear',
      validator(colorScale: ColorScale) {
        return isFunction(colorScale) || (colorScale as string) in scaleFunctions
      }
    },
    colorScaleEnd: {
      type: String,
      default() {
        const computedStyle = window.getComputedStyle(document.body)
        return computedStyle.getPropertyValue('--primary') || '#000'
      }
    },
    colorScaleStart: {
      type: String,
      default: '#fff'
    }
  },
  data() {
    return {
      cursorWrapperOffset: 0,
      mounted: false
    }
  },
  computed: {
    classList(): ClassListLegend {
      return {
        'scale-legend--has-cursor': this.hasCursor
      }
    },
    cursorLeft(): string {
      const left = this.cursorLeftScale(this.cursorValue)
      return isNaN(left) ? '0%' : `${left}%`
    },
    colorScaleBaseCanvas(): HTMLCanvasElement | null {
      return d3.create('canvas').attr('width', this.width).attr('height', this.height).node()
    },
    colorScaleContext(): CanvasRenderingContext2D | null {
      return this.colorScaleBaseCanvas?.getContext('2d') ?? null
    },
    colorScaleBase64(): string | null {
      if (this.mounted) {
        return this.colorScaleBaseCanvas?.toDataURL() ?? null
      }
      return null
    },
    colorScaleWidthRange(): number[] {
      return d3.range(1, this.width + 1)
    },
    hasCursor(): boolean {
      return this.cursorValue != null // double equal also tests undefined
    },
    colorScaleFunction(): ColorScaleFn {
      if (isString(this.colorScale)) {
        // @ts-ignore
        const fn: () => any = scaleFunctions[this.colorScale]
        return fn().domain([this.min, this.max]).range([this.colorScaleStart, this.colorScaleEnd])
      }
      return this.colorScale
    },
    cursorLeftScale(): d3.ScaleLinear<number, number> {
      return d3.scaleLinear().domain([this.min, this.max]).range([0, 100]).interpolate(d3.interpolateRound)
    },
    widthScaleColor(): WidthScaleFn {
      return (x: number) => {
        const value = this.widthScale(x)
        return this.colorScaleFunction(value)
      }
    },
    widthScale(): d3.ScaleLinear<number, number> {
      return d3.scaleLinear().domain([0, this.width]).range([this.min, this.max])
    }
  },
  watch: {
    async cursorValue() {
      await this.$nextTick()
      this.setCursorWrapperOffset()
    }
  },
  async mounted() {
    await this.$nextTick()
    this.setCursorWrapperOffset()
    this.setColorScaleCanvas()
    this.mounted = true
  },
  methods: {
    setCursorWrapperOffset(): void {
      const cursor = this.$el.querySelector('.scale-legend__cursor')
      if (cursor) {
        const { x: cursorX, width: cursorWidth } = cursor.getBoundingClientRect()
        const { x: legendX, width: legendWidth } = this.$el.getBoundingClientRect()
        const left = legendX - cursorX - 6
        const right = legendX + legendWidth - (cursorX + cursorWidth) + 6
        this.cursorWrapperOffset = Math.max(0, left) || Math.min(0, right)
      } else {
        this.cursorWrapperOffset = 0
      }
    },
    setColorScaleCanvas(): void {
      if (!this.colorScaleContext) {
        return
      }
      for (const x of this.colorScaleWidthRange) {
        this.colorScaleContext.fillStyle = this.widthScaleColor(x)
        this.colorScaleContext.fillRect(x, 0, 1, this.height)
      }
    }
  }
})
</script>

<template>
  <div :class="classList" class="scale-legend">
    <div class="scale-legend__bound scale-legend__bound--min">
      <slot name="legend-cursor-min" v-bind="{ min }">
        {{ min | formatNumber }}
      </slot>
    </div>
    <img :height="height" :src="colorScaleBase64" :width="width" class="scale-legend__scale" />
    <div class="scale-legend__bound scale-legend__bound--max">
      <slot name="legend-cursor-max" v-bind="{ max }">
        {{ max | formatNumber }}
      </slot>
    </div>
    <div v-if="hasCursor" :style="{ left: cursorLeft }" class="scale-legend__cursor">
      <div :style="{ transform: `translateX(${cursorWrapperOffset}px)` }" class="scale-legend__cursor__wrapper">
        <slot name="cursor" v-bind="{ value: cursorValue }">
          {{ cursorValue | formatNumber }}
        </slot>
      </div>
    </div>
  </div>
</template>

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

.scale-legend {
  position: relative;
  display: inline-block;

  &__bound,
  &__cursor {
    position: absolute;
    bottom: 100%;
    font-size: 0.8rem;

    &--min {
      left: 0;
    }

    &--max {
      right: 0;
    }
  }

  .choropleth-map--has-cursor &__bound {
    color: $text-muted;
    opacity: 0.6;
  }

  &__cursor {
    font-weight: bold;
    transform: translateX(-50%);
    left: 50%;

    &:after {
      content: '';
      border: 5px solid transparent;
      border-top-color: var(--dark, currentColor);
      position: absolute;
      left: 50%;
      top: 100%;
      transform: translateX(-50%);
    }
  }
}
</style>