Originally a static chart for an MW17 paper, brought to glorious D3 life.
The chart itself can be exported as an SVG by clicking the Download SVG
button.
Originally a static chart for an MW17 paper, brought to glorious D3 life.
The chart itself can be exported as an SVG by clicking the Download SVG
button.
height: 600 | |
license: apache-2.0 |
<!DOCTYPE html> | |
<svg width='960' height='500'></svg> | |
<script src='https://d3js.org/d3.v5.min.js'></script> | |
<script src='https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.min.js'></script> | |
<style> | |
body { | |
padding: 10px; | |
} | |
</style> | |
<script> | |
const body = d3.select('body') | |
const svg = d3.select('svg') | |
const loadingMessage = svg.append('text') | |
.attr('class', 'loading-message') | |
.attr('text-anchor', 'middle') | |
.attr('x', +svg.attr('width') / 2) | |
.attr('y', +svg.attr('height') / 2) | |
.style('font-family', 'sans-serif') | |
.text('Loading projects...') | |
fetchProjects() | |
.then(transformData) | |
.then(drawChart) | |
function calcYDomainMax (yearCounts) { | |
const max = d3.max(yearCounts, d => d.count) | |
// This rounds our max to the nearest 5 to calibrate the Y axis. | |
return Math.ceil(max / 5) * 5 | |
} | |
function drawChart (data) { | |
const { totalProjects, yearCounts } = data | |
loadingMessage.remove() | |
const margin = { | |
top: 20, | |
right: 20, | |
bottom: 40, | |
left: 50 | |
} | |
const padding = 10 | |
const width = parseInt(svg.attr('width'), 10) - margin.left - margin.right | |
const height = parseInt(svg.attr('height'), 10) - margin.top - margin.bottom | |
const g = svg.append('g') | |
.attr('transform', `translate(${margin.left},${margin.top})`) | |
const x = d3.scaleBand() | |
.rangeRound([0, width]) | |
.padding(0.1) | |
const y = d3.scaleLinear() | |
.rangeRound([height, 0]) | |
const yDomainMax = calcYDomainMax(yearCounts) | |
x.domain(yearCounts.map(d => d.year)) | |
y.domain([0, yDomainMax]) | |
const xAxis = d3.axisBottom(x) | |
.tickSize(0) | |
.tickPadding(padding) | |
const yAxis = d3.axisLeft(y) | |
.tickValues(d3.range(0, yDomainMax, 5)) | |
g.append('g') | |
.attr('class', 'x axis') | |
.attr('transform', `translate(0,${height})`) | |
.call(xAxis) | |
g.append('g') | |
.attr('class', 'y axis') | |
.call(yAxis) | |
g.append('text') | |
.attr('class', 'y legend') | |
.attr('text-anchor', 'middle') | |
.attr('x', 0 - margin.left + padding) | |
.attr('y', height / 2) | |
.attr('transform', `rotate(270,${0 - margin.left + padding},${height / 2})`) | |
.style('font-family', 'sans-serif') | |
.style('font-size', '10px') | |
.text('N projects') | |
g.append('text') | |
.attr('class', 'x legend') | |
.attr('text-anchor', 'middle') | |
.attr('x', width / 2) | |
.attr('y', height + margin.bottom) | |
.style('font-family', 'sans-serif') | |
.style('font-size', '10px') | |
.text('Year') | |
g.selectAll('.bar') | |
.data(yearCounts) | |
.enter() | |
.append('rect') | |
.attr('class', 'bar') | |
.attr('fill', 'steelblue') | |
.attr('x', d => x(d.year)) | |
.attr('y', height) | |
.attr('width', x.bandwidth()) | |
.attr('height', 0) | |
.transition() | |
.duration(1000) | |
.attr('y', d => y(d.count)) | |
.attr('height', d => height - y(d.count)) | |
g.selectAll('text.bar') | |
.data(yearCounts) | |
.enter() | |
.append('text') | |
.attr('class', 'count') | |
.attr('text-anchor', 'middle') | |
.attr('x', d => x(d.year) + x.bandwidth() / 2) | |
.attr('y', d => height - padding) | |
.style('font-family', 'sans-serif') | |
.style('font-size', '10px') | |
.text(d => d.count) | |
.transition() | |
.duration(1000) | |
.attr('y', d => y(d.count) - padding) | |
body.append('p') | |
.style('font-family', 'sans-serif') | |
.text(`Total number of projects: ${totalProjects}`) | |
body.append('div') | |
.append('button') | |
.text('Download SVG') | |
.on('click', writeDownloadLink) | |
} | |
function fetchProjects () { | |
const headers = new Headers({ | |
'Content-Type': 'application/json', | |
'Accept': 'accept: application/vnd.api+json; version=1' | |
}) | |
const query = ` | |
{ | |
projects(launchApproved: true) { | |
nodes { | |
displayName | |
launchApproved | |
launchDate | |
} | |
} | |
} | |
` | |
return fetch('https://panoptes.zooniverse.org/graphql', { | |
body: JSON.stringify({ query }), | |
headers, | |
method: 'POST' | |
}) | |
.then(response => response.json()) | |
.then(response => response.data.projects.nodes) | |
} | |
function getLaunchYear (project) { | |
const yearString = project.launchDate.substring(0, 4) | |
return parseInt(yearString, 10) | |
} | |
function transformData (projects) { | |
const yearCounts = projects.reduce((acc, project) => { | |
const launchYear = getLaunchYear(project) | |
// `findIndex` will return undefined in an empty array. | |
const yearIndex = acc.length | |
? acc.findIndex(obj => obj.year === launchYear) | |
: -1 | |
if (yearIndex === -1) { | |
acc.push({ year: launchYear, count: 1 }) | |
} else { | |
acc[yearIndex].count = acc[yearIndex].count + 1 | |
} | |
return acc | |
}, []) | |
return { | |
totalProjects: projects.length, | |
yearCounts: yearCounts.sort((a, b) => a.year - b.year) | |
} | |
} | |
function writeDownloadLink () { | |
try { | |
const isFileSaverSupported = !!new Blob() | |
} catch (e) { | |
alert('Blob not supported in this browser - try another :(') | |
} | |
const html = svg.attr('title', '') | |
.attr('version', 1.1) | |
.attr('xmlns', 'http://www.w3.org/2000/svg') | |
.node() | |
.outerHTML | |
const blob = new Blob([html], { type: 'image/svg+xml' }) | |
saveAs(blob, 'zooniverse_project_counts_by_year.svg') | |
} | |
</script> |