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