lib/maps/ChoroplethMap.vue

Summary

Maintainability
Test Coverage
<script>
import * as d3 from 'd3'
import { geoGraticule } from 'd3-geo'
import { geoRobinson } from 'd3-geo-projection'
import { debounce, clamp, get, kebabCase, keys, max, min, pickBy, values } from 'lodash'
import { feature } from 'topojson'

import config from '../config'
import ScaleLegend from '../components/ScaleLegend'
import chart from '../mixins/chart'

export default {
  name: 'ChoroplethMap',
  components: {
    ScaleLegend
  },
  filters: {
    formatNumber: d3.format(',')
  },
  mixins: [chart],
  provide() {
    const parent = {}
    const props = ['mapProjection', 'mapRect', 'mapTransform', 'rotatingMapProjection']
    for (const prop of props) {
      Object.defineProperty(parent, prop, { enumerable: true, get: () => this[prop] })
    }
    return { parent }
  },
  props: {
    /**
     * Covers the empty values with a hatched pattern.
     */
    hatchEmpty: {
      type: Boolean
    },
    /**
     * Hide the legend of the map.
     */
    hideLegend: {
      type: Boolean
    },
    /**
     * Change the scale function used to get calculate a feature color.
     */
    featureColorScale: {
      type: Function,
      default: null
    },
    /**
     * Change the color of the outline.
     */
    outlineColor: {
      type: String,
      default: 'currentColor'
    },
    /**
     * Change the color of the graticule.
     */
    graticuleColor: {
      type: String,
      default: 'currentColor'
    },
    /**
     * Maximum value to use in the color scale.
     */
    max: {
      type: Number,
      default: null
    },
    /**
     * Minimum value to use in the color scale.
     */
    min: {
      type: Number,
      default: null
    },
    /**
     * If true the map should be clickable (and zoom on a given feature).
     */
    clickable: {
      type: Boolean
    },
    /**
     * Field in the topojson containing all the feature objects.
     */
    topojsonObjects: {
      type: String,
      default: 'countries1'
    },
    /**
     * Field in the topojson objects containing the id of a feature. This field supports dot notation for nested values.
     */
    topojsonObjectsPath: {
      type: [String, Array],
      default: 'id'
    },
    /**
     * URL of the topojson.
     */
    topojsonUrl: {
      type: String,
      default: () => config.get('map.topojson.world-countries-sans-antarctica')
    },
    /**
     * Duration of the transitions.
     */
    transitionDuration: {
      type: Number,
      default: 750
    },
    /**
     * If true the user will be able to navigate in the map with drag and mouse wheel.
     */
    zoomable: {
      type: Boolean
    },
    /**
     * Set to true if your projection is spherical.
     */
    spherical: {
      type: Boolean
    },
    /**
     * Minium zoom value.
     */
    zoomMin: {
      type: Number,
      default: 1
    },
    /**
     * Maximum zoom value.
     */
    zoomMax: {
      type: Number,
      default: 8
    },
    /**
     * Initial zoom value.
     */
    zoom: {
      type: Number,
      default: null
    },
    /**
     * Initial center of the map.
     */
    center: {
      type: Array,
      default: null
    },
    /**
     * Projection object from d3 to draw the features.
     * @see https://d3js.org/d3-geo/projection
     */
    projection: {
      type: Function,
      default: geoRobinson
    },
    /**
     * If true the map will display an sphere outline arround the world.
     */
    outline: {
      type: Boolean
    },
    /**
     * If true the map will display a graticule grid (representing parallels and meridians).
     */
    graticule: {
      type: Boolean
    },
    /**
     * Maximum height used by the map.
     */
    height: {
      type: String,
      default: '300px'
    },
    /**
     * Neutral color of the map's features.
     */
    color: {
      type: String,
      default: '#fff'
    },
    /**
     * Neutral color of the map's features in social mode.
     */
    socialColor: {
      type: String,
      default: '#000'
    }
  },
  data() {
    return {
      mapTransform: { k: 1, x: 0, y: 0 },
      mapRect: { width: 0, height: 0 },
      featureCursor: null,
      featureZoom: null,
      topojson: null,
      topojsonPromise: null
    }
  },
  computed: {
    sphericalCenter() {
      const [lng = 0, lat = 0] = this.center ?? [0, 0]
      return [-lng, -lat]
    },
    planarCenter() {
      const [lng = 0, lat = 0] = this.center ?? [0, 0]
      return [lng, lat]
    },
    featureColorScaleEnd() {
      if (this.mounted) {
        const computedStyle = window.getComputedStyle(this.map.node())
        return computedStyle.getPropertyValue('--primary') || '#f00'
      }
      return '#f00'
    },
    featureColorScaleStart() {
      // `socialMode` is always different from null but accessing it will make
      // this computed property reactive.
      if (this.mounted && this.socialMode !== null) {
        const computedStyle = window.getComputedStyle(this.map.node())
        return computedStyle.getPropertyValue('color') || '#fff'
      }
      return '#fff'
    },
    featureColor() {
      return (d) => {
        const id = get(d, this.topojsonObjectsPath)
        if (!(id in this.loadedData)) {
          return
        }
        return this.featureColorScaleFunction(this.loadedData[id])
      }
    },
    featureColorScaleFunction() {
      if (this.featureColorScale !== null) {
        return this.featureColorScale
      }
      return this.defaultFeatureColorScale
    },
    graticuleLines() {
      return geoGraticule().step([20, 20])()
    },
    defaultFeatureColorScale() {
      return d3
        .scaleSequential()
        .domain([Math.max(1, this.minValue), this.maxValue])
        .range([this.featureColorScaleStart, this.featureColorScaleEnd])
    },
    initialFeaturePath() {
      return this.featurePath.projection(this.initialMapProjection)
    },
    initialGraticulePath() {
      return this.initialFeaturePath(this.graticuleLines)
    },
    initialMapProjection() {
      if (this.spherical) {
        const { height, width } = this.mapRect
        return this.mapProjection
          .rotate(this.sphericalCenter)
          .fitHeight(height, this.geojson)
          .translate([width / 2, height / 2])
      }
      return this.mapProjection.center(this.planarCenter)
    },
    featurePath() {
      return d3.geoPath().projection(this.mapProjection)
    },
    hasCursor() {
      return !!this.featureCursor
    },
    hasZoom() {
      return !!this.featureZoom
    },
    geojson() {
      const object = get(this.topojson, ['objects', this.topojsonObjects], null)
      return feature(this.topojson, object)
    },
    mapClass() {
      return {
        'choropleth-map--has-cursor': this.hasCursor,
        'choropleth-map--has-zoom': this.hasZoom,
        'choropleth-map--hatch-empty': this.hatchEmpty
      }
    },
    mapProjection() {
      const { height, width } = this.mapRect
      return this.projection().fitSize([width, height], this.geojson)
    },
    rotatingMapProjection() {
      const { rotateX = null, rotateY = null } = this.mapTransform
      if (rotateX !== null && rotateY !== null) {
        return this.mapProjection.rotate([rotateX, rotateY])
      }
      return this.mapProjection
    },
    mapCenter() {
      return this.mapProjection.center()
    },
    mapZoom() {
      return d3
        .zoom()
        .scaleExtent([this.zoomMin, this.zoomMax])
        .translateExtent([
          [0, 0],
          [this.mapRect.width, this.mapRect.height]
        ])
        .on('zoom', this.mapZoomed)
    },
    mapSphericalZoom() {
      return d3.zoom(this.map).scaleExtent([this.zoomMin, this.zoomMax]).on('zoom', this.mapSphericalZoomed)
    },
    mapRotate() {
      return d3.drag(this.map).on('drag', this.mapRotated)
    },
    mapHeight() {
      return this.mapRect.height
    },
    mapWidth() {
      return this.mapRect.width
    },
    mapStyle() {
      const { k = 0, x = 0, y = 0, rotateX = 0, rotateY = 0 } = this.mapTransform
      return {
        '--map-height': this.height,
        '--map-color': this.color,
        '--map-social-color': this.socialColor,
        '--map-scale': k,
        '--map-translate-x': x,
        '--map-translate-y': y,
        '--map-rotate-x': rotateX,
        '--map-rotate-y': rotateY
      }
    },
    map() {
      if (!this.mounted) {
        return null
      }
      return d3.select(this.$el).select('svg')
    },
    maxValue() {
      if (this.max !== null) {
        return this.max
      }
      return max(values(this.loadedData)) || 0
    },
    minValue() {
      if (this.min !== null) {
        return this.min
      }
      return min(values(this.loadedData)) || 0
    },
    transformOrigin() {
      return this.spherical ? '50% 50%' : '0 0'
    },
    cursorValue() {
      return get(this, ['data', this.featureCursor], null)
    },
    isReady() {
      return this.loadedData && this.mounted && this.topojson
    }
  },
  watch: {
    socialMode() {
      this.draw()
    },
    data() {
      this.update()
    },
    featureZoom() {
      this.setFeaturesClasses()
    },
    featureCursor() {
      this.setFeaturesClasses()
    }
  },
  async created() {
    await new Promise((resolve) => this.$on('loaded', resolve))
    await this.loadTopojson()
    this.draw()
    this.$on('resized', this.debouncedDraw)
  },
  methods: {
    debouncedDraw: debounce(function () {
      this.draw()
    }, 10),
    prepare() {
      // Set the map sizes
      this.$set(this, 'mapRect', this.$el.getBoundingClientRect())
      // Remove any existing country
      this.map.selectAll('.choropleth-map__main__outline > *').remove()
      this.map.selectAll('.choropleth-map__main__graticule > *').remove()
      this.map.selectAll('.choropleth-map__main__features > *').remove()
      // Return the map to allow chaining
      return this.map
    },
    prepareZoom() {
      // User can zoom on the map
      if (this.zoomable && this.spherical) {
        this.map.call(this.mapRotate).call(this.mapSphericalZoom)
      } else if (this.zoomable) {
        this.map.call(this.mapZoom)
      }
      // An intial zoom value is given
      if (this.zoom || this.spherical) {
        this.applyZoom(this.zoom ?? this.zoomMin, 0)
      }
    },
    draw() {
      this.prepare()
      this.drawOutline()
      this.drawGraticule()
      this.drawFeatures()
      this.prepareZoom()
    },
    drawOutline() {
      this.map
        .select('.choropleth-map__main__outline')
        .append('path')
        .attr('d', this.initialFeaturePath({ type: 'Sphere' }))
        .attr('stroke', this.outlineColor)
    },
    drawGraticule() {
      this.map
        .select('.choropleth-map__main__graticule')
        .append('path')
        .attr('d', this.initialGraticulePath)
        .attr('stroke', this.graticuleColor)
    },
    drawFeatures() {
      const features = this.map
        .select('.choropleth-map__main__features')
        .selectAll('.choropleth-map__main__features__item')
        .data(this.geojson.features)
        .enter()
        .append('path')

      features
        .attr('class', this.featureClass)
        .attr('d', this.initialFeaturePath)
        .on('mouseover', this.featureMouseOver)
        .on('mouseleave', this.featureMouseLeave)
        .on('click', this.mapClicked)
        .style('color', this.featureColor)
    },
    update() {
      // Bind geojson features to path
      this.map
        .selectAll('.choropleth-map__main__features__item')
        .data(this.geojson.features)
        .attr('class', this.featureClass)
        .style('color', this.featureColor)
    },
    featureClass(d) {
      return keys(pickBy(this.featureClassObject(d), (value) => value)).join(' ')
    },
    featureClassObject(d) {
      const pathClass = 'choropleth-map__main__features__item'
      const id = get(d, this.topojsonObjectsPath)
      return {
        [pathClass]: true,
        [`${pathClass}--identifier-${kebabCase(id)}`]: true,
        [`${pathClass}--empty`]: !(id in this.loadedData),
        [`${pathClass}--zoomed`]: this.featureZoom === id,
        [`${pathClass}--cursored`]: this.featureCursor === id
      }
    },
    featureMouseLeave() {
      this.featureCursor = null
    },
    featureMouseOver(_, d) {
      const id = get(d, this.topojsonObjectsPath)
      this.featureCursor = id in this.loadedData ? id : null
    },
    setFeaturesClasses() {
      this.map.selectAll('.choropleth-map__main__features__item').attr('class', this.featureClass)
    },
    async loadTopojson() {
      if (!this.topojsonPromise) {
        this.topojsonPromise = d3.json(this.topojsonUrl)
        this.topojson = await this.topojsonPromise
      }
      return this.topojsonPromise
    },
    async mapClicked(event, d) {
      /**
       * A click on a feature
       * @event click
       * @param Clicked feature
       */
      this.$emit('click', d)
      // Don't zoom on the map feature
      if (!this.clickable) {
        return
      }
      if (this.featureZoom === get(d, this.topojsonObjectsPath)) {
        return this.reapplyZoom(event, d)
      }
      await this.applyFeatureZoom(d, d3.pointer(event, this.map.node()))
      /**
       * A zoom on a feature ended
       * @event zoomed
       * @param Zoomed feature
       */
      this.$emit('zoomed', d)
    },
    mapSphericalZoomed({ transform: { k } }) {
      const transform = `scale(${k})`
      this.mapTransform = { ...this.mapTransform, k}
      this.applyTransformToTrackedElements(transform)
    },
    mapZoomed({ transform }) {
      this.mapTransform = transform
      this.applyTransformToTrackedElements(transform)
    },
    mapRotated(event) {
      const { yaw, pitch } = this.calculateRotation(event)
      this.applyRotation(yaw, pitch)
    },
    calculateRotation(event) {
      const sensitivity = 75
      const k = sensitivity / this.mapProjection.scale()
      const [rotateX, rotateY] = this.mapProjection.rotate()
      const yaw = rotateX + event.dx * k
      const pitch = rotateY - event.dy * k

      return { yaw, pitch }
    },
    applyTransformToTrackedElements(transform) {
      this.map.selectAll('.choropleth-map__main__tracked').attr('transform', transform)
    },
    applyRotation(rotateX, rotateY) {
      this.mapTransform = { ...this.mapTransform, rotateX, rotateY }
      const featuresPaths = this.initialFeaturePath.projection(this.rotatingMapProjection)
      const graticulePaths = featuresPaths(this.graticuleLines)
      this.map.selectAll('g.choropleth-map__main__features path').attr('d', featuresPaths)
      this.map.selectAll('g.choropleth-map__main__graticule path').attr('d', graticulePaths)
    },
    applyZoomIdentity(zoomIdentity, pointer = null, transitionDuration = this.transitionDuration) {
      return this.map
        .transition()
        .duration(transitionDuration)
        .call(this.mapZoom.transform, zoomIdentity, pointer)
        .end()
    },
    reapplyZoom() {
      this.mapTransform = { k: 1, x: 0, y: 0 }
      this.applyZoomIdentity(d3.zoomIdentity)
      this.featureZoom = null
      this.emitResetEvent()
    },
    emitResetEvent() {
      /**
       * The zomm on the map was reset to its initial <slot ate></slot>
       * @event reset
       */
      this.$emit('reset')
    },
    calculateFeatureZoomIdentity(d) {
      const { height, width } = this.mapRect
      const [[x0, y0], [x1, y1]] = this.featurePath.bounds(d)
      const scale = Math.min(8, 0.9 / Math.max((x1 - x0) / width, (y1 - y0) / height))
      const translateX = -(x0 + x1) / 2
      const translateY = -(y0 + y1) / 2
      return d3.zoomIdentity
        .translate(width / 2, height / 2)
        .scale(scale)
        .translate(translateX, translateY)
    },
    applyFeatureZoom(d, pointer = [0, 0]) {
      const zoomIdentity = this.calculateFeatureZoomIdentity(d)
      this.featureZoom = get(d, this.topojsonObjectsPath)
      this.mapTransform = { k: zoomIdentity.k, x: zoomIdentity.x, y: zoomIdentity.y }
      return this.applyZoomIdentity(zoomIdentity, pointer)
    },
    applyZoom(zoom, transitionDuration = this.transitionDuration) {
      const zoomScale = clamp(zoom, this.minZoom, this.maxZoom)
      if (this.spherical) {
        return this.setSphericalZoom(zoomScale, transitionDuration)
      } else {
        return this.setPlanarZoom(zoomScale, transitionDuration)
      }
    },
    setSphericalZoom(zoomScale, transitionDuration) {
      const zoomIdentity = d3.zoomIdentity.scale(zoomScale)
      this.mapTransform = { ...this.mapTransform, k: zoomScale }
      return this.applyZoomIdentity(zoomIdentity, null, transitionDuration)
    },
    setPlanarZoom(zoomScale, transitionDuration) {
      const { height, width } = this.mapRect
      const [x, y] = this.mapProjection(this.mapCenter)
      const [translateX, translateY] = [width / 2 - zoomScale * x, height / 2 - zoomScale * y]
      const zoomIdentity = d3.zoomIdentity.translate(translateX, translateY).scale(zoomScale)
      this.mapTransform = { k: zoomScale, x: translateX, y: translateY }
      return this.applyZoomIdentity(zoomIdentity, null, transitionDuration)
    }
  }
}
</script>

<template>
  <div class="choropleth-map" :class="mapClass" :style="mapStyle" @click="draw">
    <svg class="choropleth-map__main" :viewbox="`0 0 ${mapRect.width} ${mapRect.height}`">
      <pattern id="diagonalHatch" width="1" height="1" patternTransform="rotate(45 0 0)" patternUnits="userSpaceOnUse">
        <rect width="1" height="1" :fill="featureColorScaleEnd" />
        <line x1="0" y1="0" x2="0" y2="1" :style="{ stroke: featureColorScaleStart, strokeWidth: 1 }" />
      </pattern>
      <g class="choropleth-map__main__tracked" :transform-origin="transformOrigin">
        <g v-if="graticule" class="choropleth-map__main__graticule"></g>
        <g class="choropleth-map__main__features"></g>
        <g v-if="outline" class="choropleth-map__main__outline"></g>
        <slot v-if="isReady" />
      </g>
    </svg>
    <scale-legend
      v-if="!hideLegend && isReady"
      :color-scale-end="featureColorScaleEnd"
      :color-scale-start="featureColorScaleStart"
      :color-scale="featureColorScaleFunction"
      :cursor-value="cursorValue"
      :max="maxValue"
      :min="minValue"
      class="choropleth-map__legend"
    >
      <template #cursor="{ value }">
        <slot name="legend-cursor" v-bind="{ value, identifier: featureCursor }" />
      </template>
    </scale-legend>
  </div>
</template>

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

.choropleth-map {
  --map-scale: 1;
  --map-color: #fff;
  --map-social-color: #000;

  position: relative;

  &__main {
    min-height: var(--map-height, 300px);
    height: 100%;
    width: 100%;
    color: var(--map-color);

    .chart--social-mode & {
      color: var(--map-social-color);
    }

    &:deep(.choropleth-map__main__outline),
    &:deep(.choropleth-map__main__graticule) {
      fill: transparent;
      pointer-events: none;
      stroke-width: calc(1px / var(--map-scale, 1));
    }

    &:deep(.choropleth-map__main__features__item) {
      stroke: currentColor;
      stroke-width: calc(1px / var(--map-scale, 1));
      fill: currentColor;
      transition: opacity 750ms, filter 750ms, fill 750ms;

      .choropleth-map__main__features__item--empty {
        opacity: 0.8;

        .choropleth-map--hatch-empty & {
          opacity: 0.3;
          fill: url('#diagonalHatch');
        }
      }

      .choropleth-map--has-zoom &:not(.choropleth-map__main__features__item--zoomed) {
        filter: grayscale(90%);
      }
    }
  }

  &__legend {
    position: absolute;
    left: 0;
    bottom: 0;
  }
}
</style>