Skip to content

Instantly share code, notes, and snippets.

@rogerhutchings
Last active May 26, 2020 09:20
Show Gist options
  • Save rogerhutchings/c6c9e71832726f55ecb5d78e1e4a0e21 to your computer and use it in GitHub Desktop.
Save rogerhutchings/c6c9e71832726f55ecb5d78e1e4a0e21 to your computer and use it in GitHub Desktop.
Total number of projects released per year on the Zooniverse

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment