Last active
March 13, 2018 06:29
-
-
Save josephcc/64fcd9f4492b6f701b8a2ed5380a7e2a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from 'react' | |
| import ReactDOM from 'react-dom' | |
| import { sortBy, last, sortedIndexBy } from 'lodash' | |
| import { RadioGroup, RadioButton } from 'react-radio-buttons' | |
| let d3 = Object.assign({}, | |
| require('d3-shape'), | |
| require('d3-scale'), | |
| require('d3-axis'), | |
| require('d3-selection'), | |
| require('d3-array'), | |
| require('d3-ease'), | |
| require('d3-hierarchy'), | |
| require('d3-color'), | |
| require('d3-fetch'), | |
| require('d3-transition'), | |
| require('d3-time-format'), | |
| ) | |
| export default class Homework extends React.Component { | |
| static defaultProps = { | |
| width: 1200, | |
| height: 600, | |
| margin: {top: 20, right: 10, bottom: 50, left: 50}, | |
| transitionDuration: 1000, | |
| fontSize: 10, | |
| yDomain: [19, 61], | |
| xDomain: [new Date(2017, 3, 15), new Date(2018, 10, 6)], | |
| csvUrl: 'https://projects.fivethirtyeight.com/generic-ballot-data/generic_topline.csv', | |
| csvUrl2: 'https://projects.fivethirtyeight.com/generic-ballot-data/generic_polllist.csv', | |
| subgroups: ['All polls', 'Voters', 'Adults'], | |
| demColor: d3.hsl('#008ED5'), | |
| repColor: d3.hsl('#FF2701') | |
| } | |
| constructor(props) { | |
| super(props) | |
| this.state = { | |
| data: [], | |
| data2: [], | |
| subgroup: this.props.subgroups[0] | |
| } | |
| d3.csv(this.props.csvUrl, (row) => { | |
| row.date = d3.timeParse('%m/%e/%Y')(row.modeldate) | |
| return row | |
| }).then((data) => { | |
| this.setState({data: data}) | |
| this.update() | |
| }) | |
| d3.csv(this.props.csvUrl2, (row) => { | |
| row.date = d3.timeParse('%m/%e/%Y')(row.enddate) | |
| row.samplesize = parseInt(row.samplesize) | |
| return row | |
| }).then((data) => { | |
| this.setState({data2: data}) | |
| this.update() | |
| }) | |
| } | |
| _dynamicLine(_day) { | |
| if (_day === undefined) { | |
| _day = last(this.data) | |
| } | |
| this.day | |
| .attr('transform', `translate(${this.xScale(_day.date)}, 0)`) | |
| this.day.select('.date') | |
| .text(d3.timeFormat('%b %d, %Y')(_day.date)) | |
| this.day.select('.demNum') | |
| .attr('y', this.yScale(_day.dem_estimate)) | |
| .text(`${Math.round(_day.dem_estimate)}`) | |
| .append('tspan') | |
| .attr('fill', 'black') | |
| .attr('stroke-width', 0) | |
| .attr('font-size', 14) | |
| .attr('font-weight', 'bold') | |
| .attr('dy', -12) | |
| .text('%') | |
| .append('tspan') | |
| .attr('fill', 'black') | |
| .attr('stroke-width', 0) | |
| .attr('font-size', 14) | |
| .attr('font-weight', 'bold') | |
| .attr('dx', 2) | |
| .text('Democrats') | |
| this.day.select('.repNum') | |
| .attr('y', this.yScale(_day.rep_estimate)) | |
| .text(`${Math.round(_day.rep_estimate)}`) | |
| .append('tspan') | |
| .attr('fill', 'black') | |
| .attr('stroke-width', 0) | |
| .attr('font-size', 14) | |
| .attr('font-weight', 'bold') | |
| .attr('dy', -12) | |
| .text('%') | |
| .append('tspan') | |
| .attr('fill', 'black') | |
| .attr('stroke-width', 0) | |
| .attr('font-size', 14) | |
| .attr('font-weight', 'bold') | |
| .attr('dx', 2) | |
| .text('Republicans') | |
| } | |
| mousemove(a, b, c) { | |
| let x = this.xScale.invert(d3.mouse(c[0])[0] - this.props.margin.left) | |
| let idx = sortedIndexBy(this.data, {date: x}, (d) => d.date) | |
| idx = Math.min(this.data.length - 1, idx) | |
| this._dynamicLine(this.data[idx]) | |
| } | |
| initD3(element) { | |
| let width = this.props.width - this.props.margin.left - this.props.margin.right | |
| let height = this.props.height - this.props.margin.top - this.props.margin.bottom | |
| d3.select(element).select('svg').remove() | |
| this.canvas = d3.select(element) | |
| .append('svg') | |
| .attr('width', this.props.width) | |
| .attr('height', this.props.height) | |
| .on('mousemove', this.mousemove.bind(this)) | |
| .on('mouseleave', this._dynamicLine.bind(this)) | |
| .append('g') | |
| .attr('transform', `translate(${this.props.margin.left}, ${this.props.margin.top})`) | |
| this.yScale = d3 | |
| .scaleLinear() | |
| .range([height, 0]) | |
| .domain(this.props.yDomain) | |
| let yAxis = d3 | |
| .axisLeft(this.yScale) | |
| .tickSize(-width) | |
| .ticks(5) | |
| .tickFormat((t, idx) => (idx === 4 ? `${t}%` : `${t}`)) | |
| this.xScale = d3 | |
| .scaleTime() | |
| .range([0, width]) | |
| .domain(this.props.xDomain) | |
| this.rxScale = d3 | |
| .scaleTime() | |
| .domain([0, width]) | |
| .range(this.props.xDomain) | |
| let xAxis = d3 | |
| .axisBottom(this.xScale) | |
| .ticks(19) | |
| .tickSize(-height) | |
| .tickFormat((time, idx) => { | |
| if (idx === 0 || time.getMonth() === 0) { | |
| return d3.timeFormat('%b %Y')(time) | |
| } | |
| return d3.timeFormat('%b')(time) | |
| }) | |
| xAxis = this.canvas.append('g') | |
| .attr('transform', `translate(0, ${height})`) | |
| .call(xAxis) | |
| yAxis = this.canvas.append('g') | |
| .call(yAxis) | |
| let dday = this.canvas | |
| .append('g') | |
| .attr('transform', `translate(${this.xScale(last(this.props.xDomain))}, 0)`) | |
| dday | |
| .append('line') | |
| .attr('stroke', 'black') | |
| .attr('stroke-width', 1) | |
| .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', height) | |
| dday | |
| .append('text') | |
| .attr('x', -4).attr('y', this.yScale(58)) | |
| .attr('text-anchor', 'end') | |
| .attr('font-weight', 'bold') | |
| .text('Election Day') | |
| dday | |
| .append('text') | |
| .attr('x', -4).attr('y', this.yScale(58) + 16) | |
| .attr('text-anchor', 'end') | |
| .text('NOV. 6, 2018') | |
| xAxis | |
| .selectAll('text') | |
| .style('text-anchor', 'middle') | |
| .attr('font-size', '1.4em') | |
| .attr('fill', 'gray') | |
| .attr('dx', '0em') | |
| .attr('dy', '1em') | |
| yAxis | |
| .selectAll('text') | |
| .style('text-anchor', 'start') | |
| .attr('font-size', '1.4em') | |
| .attr('fill', 'gray') | |
| .attr('dx', '-2em') | |
| xAxis | |
| .selectAll('line') | |
| .attr('stroke', 'lightgray') | |
| yAxis | |
| .selectAll('line') | |
| .attr('stroke', 'lightgray') | |
| xAxis | |
| .selectAll('path') | |
| .attr('stroke-width', 0) | |
| yAxis | |
| .selectAll('path') | |
| .attr('stroke-width', 0) | |
| } | |
| _plotArea(data, column0, column1, color, duration, delay) { | |
| data = sortBy(data, 'date') | |
| let canvas = this.canvas.append('g') | |
| let area = canvas.selectAll('.area') | |
| .data([data]) | |
| area | |
| .enter().append('path').classed('area', true) | |
| .merge(area) | |
| .attr('stroke-width', 3) | |
| .style('fill', '#0000') | |
| .attr('d', d3.area() | |
| .x((d) => this.xScale(d.date)) | |
| .y0((d) => this.yScale(d[column1])) | |
| .y1((d) => this.yScale(d[column0])) | |
| ) | |
| canvas.selectAll('.area').transition().duration(duration).delay(delay).ease(d3.easeLinear) | |
| .style('fill', color) | |
| area.exit().remove() | |
| } | |
| _plotLine(data, column, color, duration, delay) { | |
| data = sortBy(data, 'date') | |
| let canvas = this.canvas.append('g') | |
| let line = canvas.selectAll('.line') | |
| .data([data]) | |
| line | |
| .enter().append('path').classed('line', true) | |
| .merge(line) | |
| .style('fill', 'none') | |
| .attr('stroke', color) | |
| .attr('stroke-width', 3) | |
| .attr('stroke-dasharray', 2000) | |
| .attr('stroke-dashoffset', 2000) | |
| .attr('d', d3.line() | |
| .x((d) => this.xScale(d.date)) | |
| .y((d) => this.yScale(d[column])) | |
| ) | |
| canvas.selectAll('.line').transition().duration(duration).delay(delay).ease(d3.easeLinear) | |
| .attr('stroke-dashoffset', 0) | |
| line.exit().remove() | |
| } | |
| _plotDots(data, column, color, duration, delay) { | |
| data = sortBy(data, 'date') | |
| let canvas = this.canvas.append('g') | |
| let dots = canvas.selectAll('.dots') | |
| .data(data) | |
| dots = dots | |
| .enter().append('circle') | |
| .merge(dots) | |
| .attr('class', 'dots') | |
| .attr('cx', (d) => this.xScale(d.date)) | |
| .attr('cy', (d) => this.yScale(d[column])) | |
| .attr('stroke', 'white') | |
| .attr('stroke-width', 0.5) | |
| .attr('r', 0) | |
| .attr('fill', color) | |
| dots.transition().duration(duration).delay(delay).ease(d3.easeLinear) | |
| .attr('r', (d) => this.rScale(d.samplesize)) | |
| dots.exit().remove() | |
| } | |
| _plotLegend() { | |
| if (this.legend !== undefined) | |
| this.legend.remove() | |
| let steps = [1000, 2000, 3000, 4000] | |
| let height = 80 | |
| this.legend = this.canvas.append('g') | |
| .attr('transform', `translate(${this.props.width - 160}, ${this.props.height - 120 - this.props.margin.top - this.props.margin.bottom})`) | |
| this.legend.append('text') | |
| .attr('x', 0) | |
| .attr('y', 10) | |
| .attr('fill', '#555') | |
| .text('Sample Size') | |
| let legend2 = this.legend.append('g') | |
| .attr('transform', `translate(0, 24)`) | |
| .selectAll('.dot') | |
| .data(steps) | |
| legend2 = legend2 | |
| .enter().append('circle') | |
| .merge(legend2) | |
| .attr('class', 'dot') | |
| .attr('cy', (d, i) => i*(height/steps.length)) | |
| .attr('cx', 5) | |
| .attr('fill', '#555') | |
| .attr('stroke-width', 0) | |
| .attr('r', (d) => this.rScale(d)) | |
| let legend3 = this.legend.append('g') | |
| .attr('transform', `translate(0, 24)`) | |
| .selectAll('.text') | |
| .data(steps) | |
| legend3 = legend3 | |
| .enter().append('text') | |
| .merge(legend3) | |
| .attr('class', 'text') | |
| .attr('y', (d, i) => i*(height/steps.length) + 6) | |
| .attr('x', 5 + 10) | |
| .attr('fill', '#555') | |
| .attr('stroke-width', 0) | |
| .text((d) => d) | |
| } | |
| update() { | |
| let data = this.state.data | |
| let data2 = this.state.data2 | |
| if (data.length === 0 || data2.length === 0) { | |
| return | |
| } | |
| this.data = data.filter((d) => d.subgroup === this.state.subgroup) | |
| this.data = sortBy(this.data, 'date') | |
| this.data2 = data2.filter((d) => d.subgroup === this.state.subgroup) | |
| let _sampleSizes = this.state.data2.map((d) => d.samplesize) | |
| this.rScale = d3 | |
| .scaleSqrt() | |
| .range([1, 5]) | |
| .domain([Math.min(..._sampleSizes), Math.max(..._sampleSizes)]) | |
| this._plotLegend() | |
| this.props.demColor.opacity = 0.1 | |
| this.props.repColor.opacity = 0.1 | |
| this._plotArea(this.data, 'dem_hi', 'dem_lo', this.props.demColor, 500, 750) | |
| this._plotArea(this.data, 'rep_hi', 'rep_lo', this.props.repColor, 500, 750) | |
| this.props.demColor.opacity = 0.25 | |
| this.props.repColor.opacity = 0.25 | |
| this._plotDots(this.data2, 'dem', this.props.demColor, 750, 0) | |
| this._plotDots(this.data2, 'rep', this.props.repColor, 750, 0) | |
| this.props.demColor.opacity = 1.0 | |
| this.props.repColor.opacity = 1.0 | |
| this._plotLine(this.data, 'dem_estimate', '#008ED5', 1500, 1250) | |
| this._plotLine(this.data, 'rep_estimate', '#FF2701', 1500, 1250) | |
| if (this.day !== undefined) { | |
| this.day.remove() | |
| } | |
| this.day = this.canvas | |
| .append('g') | |
| this.day | |
| .append('line') | |
| .attr('stroke', 'black') | |
| .attr('stroke-width', 1) | |
| .attr('stroke-dasharray', '4, 2') | |
| .attr('x1', 0).attr('y1', 0).attr('x2', 0).attr('y2', this.props.height - this.props.margin.left - this.props.margin.right) | |
| this.day | |
| .append('text') | |
| .attr('class', 'date') | |
| .attr('text-anchor', 'start') | |
| .attr('font-weight', 'bold') | |
| .attr('x', 4).attr('y', this.yScale(58) + 2) | |
| this.day | |
| .append('text') | |
| .attr('class', 'demNum') | |
| .attr('fill', this.props.demColor) | |
| .attr('x', 4) | |
| .attr('font-size', 40) | |
| .attr('font-weight', 'bold') | |
| .attr('stroke', 'white') | |
| .attr('width', 1) | |
| this.day | |
| .append('text') | |
| .attr('class', 'repNum') | |
| .attr('fill', this.props.repColor) | |
| .attr('x', 4) | |
| .attr('font-size', 40) | |
| .attr('font-weight', 'bold') | |
| .attr('stroke', 'white') | |
| .attr('width', 1) | |
| this._dynamicLine(last(this.data)) | |
| } | |
| componentDidUpdate() { | |
| this.initD3(ReactDOM.findDOMNode(this.refs.homework)) | |
| this.update() | |
| } | |
| componentDidMount() { | |
| this.initD3(ReactDOM.findDOMNode(this.refs.homework)) | |
| this.update() | |
| } | |
| render() { | |
| let sourceUrl = 'https://gist.github.com/64fcd9f4492b6f701b8a2ed5380a7e2a' | |
| return ( | |
| <div style={{padding: '20px'}}> | |
| <h2>Are Democrats/Republicans Winning The Race For Congress?</h2> | |
| <h5> | |
| Basically a clone of <a target='_blank' href='https://projects.fivethirtyeight.com/congress-generic-ballot-polls/'>this</a> with some extra features. Mouse over to see past numbers. Written in D3.js. | |
| </h5> | |
| <div className='homework' ref='homework' style={{}}/> | |
| <div style={{width: '800px'}}> | |
| <RadioGroup onChange={ (subgroup) => this.setState({subgroup: subgroup}) } value={this.state.subgroup} horizontal> | |
| { this.props.subgroups.map((subgroup) => { | |
| return ( | |
| <RadioButton value={subgroup} iconSize={20} key={subgroup}> | |
| {subgroup} | |
| </RadioButton> | |
| ) | |
| })} | |
| </RadioGroup> | |
| </div> | |
| <h5> | |
| Source code: <a target='_blank' href={sourceUrl}>{sourceUrl}</a> | |
| </h5> | |
| </div>) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment