Skip to content

Instantly share code, notes, and snippets.

@mmazanec22
Last active January 26, 2018 16:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mmazanec22/d3cc26bc753f960cdb05f5075d5eae2f to your computer and use it in GitHub Desktop.
Save mmazanec22/d3cc26bc753f960cdb05f5075d5eae2f to your computer and use it in GitHub Desktop.
Accessible Bar Chart

Accessible Bar Chart Prototype

This is a quick and imperfect prototype to illustrate two ideas that might allow a blind or visually impaired user to experience a bar chart. One concept is using pitch to indicate the relative height of the different bars (building upon this gist: http://bl.ocks.org/aholachek/6e18a82c0f0ada144b854f788c07d7a4). To try this, any user may use the mouse or the arrow keys to navigate the bars.

The other idea is to apply aria labels and roles to make the different svg elements available to screen readers. To try this, turn on the screen reader (on a Mac, cmd + F5), tab your way into the chart, then use the arrow keys to navigate the bars. The screen reader will announce x and y values (the pitch will still sound as well-- in a real implementation, this should probably be turned off by default so as not to conflict with the screen reader).

Feedback and improvements are welcome!

class BarChart {
constructor(parentElement, inputData, xKey) {
this.parentElement = d3.select(parentElement);
const data = d3.nest()
.key(d => d[xKey])
.entries(inputData);
this.width = this.parentElement.style('width').replace('px', '');
this.height = this.parentElement.style('height').replace('px', '');
this.verticalMargins = this.height * 0.1;
this.horizontalMargins = this.width * 0.1;
this.graphWidth = this.width - (this.horizontalMargins * 2);
this.graphHeight = this.height - (this.verticalMargins * 2);
this.context = new AudioContext();
this.x = d3.scaleBand()
.rangeRound([0, this.graphWidth])
.padding(0.1)
.domain(data.map(d => d.key));
const yMin = d3.min(data, d => d.values.length);
const yMax = d3.max(data, d => d.values.length);
this.y = d3.scaleLinear()
.rangeRound([this.graphHeight, 0])
.domain([0, yMax]);
data.map(function (d) {
const moreTonalMax = (yMax - yMin) < 36 ? (parseInt(yMax / 12) + 12) : (yMax - yMin);
const span = Math.min(moreTonalMax, 36);
const n = d3.scaleLinear()
.domain([yMin, yMax])
.range([0, span]);
d.pitch = 220 * Math.pow(Math.pow(2, 1 / span), n(d.values.length));
});
this.draw(data);
this.bars = d3.selectAll('.bar').nodes(); // should be selection of all bars
this.highlightedBarIndex = null;
this.parentElement.on('keydown', () => this.handleArrowKey());
}
handleBarFocus(data, index) {
this.highlightedBarIndex = index;
const highlightedBar = d3.select(this.bars[index]);
const volumeScale = d3.scaleLinear().domain([100, 1500]).range([2, 0.3]);
d3.selectAll(this.bars)
.classed('highlighted', false)
.attr('tabindex', '-1');
highlightedBar
.classed('highlighted', true)
.attr('tabindex', '0');
const self = this;
highlightedBar.attr('d', function (d) {
// Audio functionality from: http://bl.ocks.org/aholachek/6e18a82c0f0ada144b854f788c07d7a4
const oscillator = self.context.createOscillator();
const gainNode = self.context.createGain();
gainNode.gain.value = volumeScale(d.pitch);
oscillator.type = 'triangle';
oscillator.frequency.value = d.pitch; // Hz
// Connect the oscillator to our speakers after passing it
// through the gainNode to modulate volume
oscillator.connect(gainNode);
gainNode.connect(self.context.destination);
// Start the oscillator now
oscillator.start();
// this rapidly ramps sound down
gainNode.gain.setTargetAtTime(0, self.context.currentTime, 0.3);
});
}
handleArrowKey() {
const pushed = d3.event.keyCode;
if (pushed !== 37 && pushed !== 39) return;
if (this.highlightedBarIndex === null) {
// If this is the first time a user has pressed an arrow key
this.highlightedBarIndex = 0;
} else if (pushed === 37) {
this.highlightedBarIndex -= 1;
} else if (pushed === 39) {
this.highlightedBarIndex += 1;
}
const numBars = this.bars.length;
// If subtracting one made it negative, go to the last bar
this.highlightedBarIndex = this.highlightedBarIndex < 0 ?
numBars + this.highlightedBarIndex : this.highlightedBarIndex % numBars;
this.bars[this.highlightedBarIndex].focus();
}
draw(inputData) {
const svg = this.parentElement.append('svg')
.attr('position', 'absolute')
.attr('preserveAspectRatio', 'xMinYMin meet')
.attr('width', this.width)
.attr('height', this.height)
.attr('role', 'group')
.attr('tabindex', '0');
const xAxis = d3.axisBottom()
.scale(this.x);
// .ticks(d3.timeYear, 1)
// .tickFormat(d => `${new Date(d).toLocaleDateString('en-US', {year: 'numeric'})}`);
const yAxis = d3.axisLeft(this.y);
// .ticks(10)
const barGroup = svg.append('g')
.attr('class', 'bar-group')
.attr('role', 'list') // so that screen readers will announce number of items in list
.attr('aria-label', 'bar graph')
.attr('tabindex', '0');
const bars = barGroup.selectAll('.bar')
.data(inputData);
bars.exit()
.transition()
.duration(750)
.attr('height', 0);
bars.enter().append('rect')
.attr('class', 'bar')
.attr('role', 'listitem') // so screen reader will know it's in the list
.attr('tabindex', '-1')
.attr('aria-label', d => `X value: ${d.key}. Y value: ${d.values.length}.`)
.attr('x', d => this.x(d.key) + this.horizontalMargins)
.attr('y', d => this.verticalMargins + this.y(d.values.length))
.attr('width', this.x.bandwidth())
.attr('height', d => this.graphHeight - this.y(d.values.length))
.on('focus', (d, i) => this.handleBarFocus(d, i))
.on('blur', function () {
d3.select(this)
.classed('highlighted', false)
.attr('tabindex', '-1');
});
const xAxisElements = svg.append('g')
.attr('role', 'presentation')
.attr('aria-hidden', 'true')
.attr('class', 'x axis')
.attr('transform', `translate(${this.horizontalMargins} ${this.graphHeight + this.verticalMargins})`)
.call(xAxis);
xAxisElements.selectAll('*')
.attr('role', 'presentation')
.attr('aria-hidden', 'true');
xAxisElements.selectAll('path, line')
.style('shape-rendering', 'crispEdges');
const yAxisElements = svg.append('g')
.attr('role', 'presentation')
.attr('aria-hidden', 'true')
.attr('class', 'y axis')
.attr('transform', `translate(${this.horizontalMargins} ${this.verticalMargins})`)
.call(yAxis);
yAxisElements.selectAll('*')
.attr('role', 'presentation')
.attr('aria-hidden', 'true');
yAxisElements.selectAll('path, line')
.style('shape-rendering', 'crispEdges');
}
}
<!DOCTYPE html>
<html>
<head>
<title>Accessible Bar Chart Prototype</title>
<script src="https://d3js.org/d3.v4.js"></script>
<script type="text/javascript" src="./bar.js"></script>
<style type="text/css">
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
div#parent {
width: 100%;
height: 100%;
}
.bar {
fill-opacity: 0.4;
stroke: white;
fill: dodgerblue;
}
.bar.highlighted {
stroke: dodgerblue;
fill-opacity: 1;
}
.axis .domain {
stroke: none;
fill: none;
}
.axis text {
fill: dodgerblue;
font-size: 0.6rem;
letter-spacing: 0.05rem;
fill-opacity: 0.8;
}
.axis line {
stroke: dodgerblue;
}
</style>
<script type="text/javascript">
function makeData(length = 40) {
const possiblies = 'sphinx of black quartz judge my vow';
return new Array(length).fill({}).map(function () {
return {
str: possiblies[Math.floor(Math.random() * possiblies.length)],
val: Math.floor(Math.random() * 10),
};
});
}
document.addEventListener('DOMContentLoaded', function () {
new BarChart(document.getElementById('parent'), makeData(), 'str');
});
</script>
</head>
<body>
<div id="parent"></div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment