fbi-cde/crime-data-frontend

View on GitHub
src/components/agency/AgencyNibrsChart.js

Summary

Maintainability
A
0 mins
Test Coverage
import { max } from 'd3-array'
import { scaleBand, scaleLinear, scaleOrdinal } from 'd3-scale'
import throttle from 'lodash.throttle'
import uniqBy from 'lodash.uniqby'
import PropTypes from 'prop-types'
import React from 'react'

import AgencyChartDetails from './AgencyChartDetails'
import XAxis from '../XAxis'
import YAxis from '../YAxis'
import { rangeYears } from '../../util/years'

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

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

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

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

  getActive = data => {
    const { yearSelected } = this.state

    let active
    const selected = data.find(d => d.data_year === yearSelected)

    if (yearSelected && selected) {
      active = selected
      active.year = selected.data_year
    } else if (yearSelected && !selected) {
      // year selected but no data reported
      active = {
        year: yearSelected,
        value: 0,
      }
    } else {
      active = data[data.length - 1]
      active.year = active.data_year
    }

    const priorYear = data.find(d => d.data_year === active.year - 1)
    return {
      active,
      priorYear,
    }
  }

  getYMax = (data, keys = []) => {
    const min = 3
    const counts = data
      .map(d => keys.map(k => d[k]))
      .reduce((accum, next) => [...accum, ...next], [min])
    return max(counts)
  }

  getNoDataYears = (data = [], since, until) => {
    const missingYears = rangeYears(since, until).filter(
      year => !data.find(d => d.data_year === year),
    )
    const zeroReportedYears = data
      .filter(d => d.value === 0)
      .map(d => d.data_year)
    const noDataYears = missingYears.concat(zeroReportedYears)

    return uniqBy(noDataYears).sort()
  }

  handleMouseOver = d => () => {
    this.updateYear(d)
  }

  updateYear = year => {
    this.setState({ yearSelected: year })
  }

  render() {
    const {
      colors,
      crime,
      data,
      mutedColors,
      since,
      size,
      submitsNibrs,
      until,
    } = this.props
    const { svgParentWidth } = this.state

    const svgWidth = svgParentWidth || size.width
    const svgHeight = svgWidth / 3
    const { margin } = size
    const width = svgWidth - margin.left - margin.right
    const height = svgHeight - margin.top - margin.bottom
    const xPadding = svgWidth < 500 ? 20 : 40

    const keys = ['value']
    const colorMap = scaleOrdinal().domain(keys).range(colors)
    const mutedColorMap = scaleOrdinal().domain(keys).range(mutedColors)
    const noun = 'offenses'
    const yMax = this.getYMax(data, keys)

    const y = scaleLinear().domain([0, yMax]).rangeRound([height, 0]).nice()

    const timeRange = rangeYears(since, until)
    const x0 = scaleBand()
      .domain(timeRange)
      .rangeRound([0 + xPadding, width - xPadding])
      .paddingInner(0.3)

    const yrRange = rangeYears(since, until)

    const x1 = scaleBand()
      .domain(keys)
      .rangeRound([0, x0.bandwidth()])
      .padding(0)

    const { active, priorYear: activePriorYear } = this.getActive(data)
    const noDataYears = this.getNoDataYears(data, since, until)


    // no data (nd) element responsive values
    const [ndHeight, ndCircle, ndTextY, ndTextSize] =
      svgWidth < 500 ? [10, 5, 2.5, 8] : [20, 8, 4, 11]

    return (
      <div>
        <AgencyChartDetails
          colors={colorMap}
          crime={crime}
          data={active}
          dataPrior={activePriorYear}
          keys={keys}
          noun={noun}
          updateYear={this.updateYear}
          yrRange={yrRange}
          until={until}
          since={since}
          nibrsDetails
        />
        <div className="mb2 h6 bold monospace black">
          Total {noun} reported by year
        </div>
        <div className="mb3 col-12" ref={ref => (this.svgParent = ref)}>
          <svg
            className="bar-chart"
            width={svgWidth}
            height={svgHeight}
            style={{ maxWidth: '100%' }}
          >
            <g transform={`translate(${margin.left}, ${margin.top})`}>
              <XAxis scale={x0} height={height} />
              <YAxis scale={y} width={width} />
              <g transform="translate(0, -0.5)">
                {data.map(d =>
                  <g key={d.year} transform={`translate(${x0(d.data_year)}, 0)`}>
                    {keys.map(k =>
                      <rect
                        key={`${d.data_year}-${k}`}
                        x={x1(k)}
                        y={y(d[k])}
                        height={Math.max(0, height - y(d[k]))}
                        width={x1.bandwidth()}
                        fill={
                          this.state.yearSelected === d.data_year
                            ? colorMap(k)
                            : mutedColorMap(k)
                        }
                        className="cursor-pointer"
                        pointerEvents="all"
                        onMouseOver={this.handleMouseOver(d.data_year)}
                      />,
                    )}
                  </g>,
                )}
                {noDataYears.map(year =>
                  <g
                    key={`ndy-${year}`}
                    transform={`translate(${x0(year) +
                      x1.bandwidth()}, ${height - ndHeight})`}
                    className="cursor-pointer no-year-data"
                    onMouseOver={this.handleMouseOver(year)}
                  >
                    <circle r={ndCircle} fill="transparent" strokeWidth="1px" />
                    <text
                      y={ndTextY}
                      textAnchor="middle"
                      style={{ fontSize: ndTextSize }}
                    >
                      ✕
                    </text>
                  </g>,
                )}
              </g>
            </g>
          </svg>
        </div>
      </div>
    )
  }
}

AgencyNibrsChart.propTypes = {
  colors: PropTypes.array.isRequired,
  crime: PropTypes.string.isRequired,
  data: PropTypes.arrayOf(PropTypes.object).isRequired,
  mutedColors: PropTypes.array.isRequired,
  since: PropTypes.number.isRequired,
  size: PropTypes.shape({
    width: PropTypes.number,
    margin: PropTypes.object,
  }).isRequired,
  submitsNibrs: PropTypes.bool.isRequired,
  until: PropTypes.number.isRequired,
}

AgencyNibrsChart.defaultProps = {
  colors: ['#702c27', '#ff5e50'],
  mutedColors: ['#f4e1df', '#faefee'],
  size: {
    width: 720,
    margin: { top: 16, right: 0, bottom: 24, left: 36 },
  },
}

export default AgencyNibrsChart