fbi-cde/crime-data-frontend

View on GitHub
src/components/trend/TrendChart.js

Summary

Maintainability
A
3 hrs
Test Coverage
/* eslint-disable react/jsx-no-bind */

import { bisectLeft, extent, max } from 'd3-array'
import { scaleLinear, scaleOrdinal, scaleTime } from 'd3-scale'
import range from 'lodash.range'
import throttle from 'lodash.throttle'
import PropTypes from 'prop-types'
import React from 'react'

import TrendChartDetails from './TrendChartDetails'
import TrendChartHover from './TrendChartHover'
import TrendChartLineSeries from './TrendChartLineSeries'
import TrendChartRapeAnnotate from './TrendChartRapeAnnotate'
import TrendChartRapeLegend from './TrendChartRapeLegend'
import XAxis from '../XAxis'
import YAxis from '../YAxis'
import { formatYear } from '../../util/formats'

class TrendChart extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      svgParentWidth: null,
      yearSelected: props.until
    }
    this.getDimensions = throttle(this.getDimensions, 20)
  }

  componentDidMount() {
    this.getDimensions()
    window.addEventListener('resize', this.getDimensions)
  }

  componentWillReceiveProps(nextProps) {
    const { initialYearSelected } = this.props
    const { initialYearSelected: next } = nextProps
    if (next === initialYearSelected) return
    this.setState({ yearSelected: next })
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.getDimensions)
  }

  getDimensions = () => {
    if (this.svgParent) {
      this.setState({ svgParentWidth: this.svgParent.clientWidth })
    }
  }

  getYearFromXPosition = xPosition => {
    const { since, until } = this.props.filters
    const { width, xPad } = this.calculateDimensions()
    const dates = range(since, until + 1).map(d => formatYear(d))
    const x = scaleTime()
      .domain(extent(dates))
      .range([xPad, width - xPad])

    const x0 = x.invert(xPosition * width)
    const i = bisectLeft(dates, x0, 1)
    const [d0, d1] = [dates[i - 1], dates[i]]
    const pt = d0 && d1 && x0 - d0 > d1 - x0 ? d1 : d0

    return pt.getFullYear()
  }

  createSeries = () => {
    const { crime, data, places } = this.props
    const isRape = crime === 'rape'
    const crimes = [isRape ? 'rape-legacy' : crime]
    if (isRape) crimes.push('rape-revised')
    const dataByYear = data.map(d => ({ ...d, date: formatYear(d.year) }))
    return places
      .map(place =>
        crimes.map(c => {
          const values = dataByYear
            .filter(d => d[place][c] && d[place][c].count)
            .map(d => ({
              date: d.date,
              year: d.year,
              population: d[place].population,
              ...d[place][c]
            }))
          return { crime: c, place, values }
        })
      )
      .reduce((a, n) => a.concat(n), [])
  }

  calculateDimensions = () => {
    const { margin, width: initialWidth } = this.props.size
    const { svgParentWidth } = this.state

    const svgWidth = svgParentWidth || initialWidth
    const svgHeight = svgWidth / 2.25
    const width = svgWidth - margin.left - margin.right
    const height = svgHeight - margin.top - margin.bottom
    const xPad = svgWidth < 500 ? 15 : 30

    return { width, height, xPad, svgHeight, svgWidth }
  }

  handleMouseMove = e => {
    // get mouse x position, relative to container
    const node = e.target
    const rect = node.getBoundingClientRect()
    const xRel = e.clientX - rect.left - node.clientLeft
    const yearSelected = this.getYearFromXPosition(xRel / rect.width)

    this.props.onChangeYear(yearSelected)
  }

  render() {
    const {
      crime,
      colors,
      places,
      onChangeYear: handleChangeYear,
      size,
      placeName,
      filters
    } = this.props

    const { yearSelected } = this.state
    const { margin } = size
    const color = scaleOrdinal(colors)
    const {
      height,
      width,
      xPad,
      svgHeight,
      svgWidth
    } = this.calculateDimensions()

    const series = this.createSeries()
    const dates = range(filters.since, filters.until + 1).map(d =>
      formatYear(d)
    )
    const rates = series
      .map(s => s.values)
      .reduce((accum, next) => accum.concat(next), [])
      .map(s => s.rate)

    const x = scaleTime()
      .domain(extent(dates))
      .range([xPad, width - xPad])
    const y = scaleLinear()
      .domain([0, max(rates)])
      .range([height, 0])
      .nice()

    const active = series.map(s => {
      const { values } = s
      const activeValue = yearSelected
        ? values.find(v => v.year === yearSelected)
        : values[values.length - 1]

      return {
        crime: s.crime,
        place: s.place,
        ...activeValue
      }
    })
    return (
      <div>
        <TrendChartDetails
          active={active}
          colors={colors}
          crime={crime}
          keys={places}
          since={filters.since}
          onChangeYear={handleChangeYear}
          until={filters.until}
          placeName={placeName}
          placeType={filters.placeType}
        />
        <div className="mb2 clearfix">
          <div className="sm-col mb1 sm-m0 fs-12 bold monospace black">
            Rate per 100,000 people, by year
          </div>
          {crime === 'rape' && <TrendChartRapeLegend />}
        </div>
        {/* eslint-disable no-return-assign */}
        <div className="mb3 col-12" ref={ref => (this.svgParent = ref)}>
          <svg width={svgWidth} height={svgHeight} style={{ maxWidth: '100%' }}>
            <g transform={`translate(${margin.left}, ${margin.top})`}>
              <XAxis
                active={active[0].date}
                scale={x}
                height={height}
                tickCt={svgWidth < 500 ? 4 : 8}
              />
              <YAxis scale={y} width={width} />
              <TrendChartLineSeries color={color} series={series} x={x} y={y} />
              {filters.until > 2013 &&
                crime === 'rape' && (
                  <TrendChartRapeAnnotate height={height} x={x} />
                )}
              <TrendChartHover
                active={active}
                color={color}
                height={height}
                x={x}
                y={y}
              />
              <rect
                width={width}
                height={height}
                fill="none"
                pointerEvents="all"
                onMouseMove={this.handleMouseMove}
              />
            </g>
          </svg>
        </div>
      </div>
    )
  }
}

TrendChart.propTypes = {
  data: PropTypes.arrayOf(PropTypes.object).isRequired,
  onChangeYear: PropTypes.func,
  initialYearSelected: PropTypes.number,
  filters: PropTypes.object.isRequired,
  placeName: PropTypes.string.isRequired,
  crime: PropTypes.string.isRequired
}

TrendChart.defaultProps = {
  size: {
    width: 735,
    margin: { top: 16, right: 0, bottom: 24, left: 32 }
  },
  colors: ['#ff5e50', '#95aabc', '#52687d'],
  onChangeYear: () => {}
}

export default TrendChart