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> |