Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ Django and NPM and then do a one time setup by running the script
This is needed to create the new admin user interactively. Following this, you can kill the server and start it
with the run configuration provided - 'RCVis', a Django server configuration.

## Accessibility
Every visualization is described for screenreaders and navigable by keyboard: pick a round, then read what the chart says. See [the accessibility docs](docs/source/accessibility.rst) for what is covered and how it fits together.

## Test Data
Test data, including real and mock elections, can be found in the `testData` directory. Example input formats can be found on [RCVFormats](https://github.com/artoonie/rcvformats/tree/main/testdata/inputs).

Expand Down
3 changes: 3 additions & 0 deletions common/viewUtils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,14 @@ def get_data_for_round_describer(graph, config):
roundDescriber = Describer(graph, config, summarizeAsParagraph=False)
humanFriendlyEventsPerRound = roundDescriber.describe_all_rounds()
humanFriendlySummary = roundDescriber.describe_initial_summary(isForVideo=False)
narration = roundDescriber.describe_narration()
faqsPerRound = json.dumps(FAQGenerator(graph, config).describe_all_rounds())

return {
'humanFriendlyEventsPerRound': json.dumps(humanFriendlyEventsPerRound),
'humanFriendlySummary': json.dumps(humanFriendlySummary),
# Plain-English narration, used for the charts' screenreader descriptions
'narrationJson': json.dumps(narration),
'faqsPerRound': faqsPerRound
}

Expand Down
66 changes: 66 additions & 0 deletions docs/source/accessibility.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Accessibility
========================

Every RCVis visualization is built to be usable without sight and without a mouse. There
is no accessibility widget or menu: the visualizations themselves are described, and
screenreader users bring their own software. Everything below is invisible to sighted
users.

Describing the charts
-------------------------------------

The charts are drawings, so each one is exposed to assistive technology as a single
described image rather than hundreds of unlabelled shapes:

* The **bar chart** SVG has ``role="img"`` with a ``<title>`` and a ``<desc>``. The
description follows the round being shown: "Showing round 2 of 3." followed by the
plain-English narration of that round.
* The **Sankey diagram** SVG has ``role="img"`` with a title and a description of how to
read it plus the election summary. Its top bar of round labels is hidden from assistive
technology.
* The **pie chart** has a visually hidden description.

The plain-English narration comes from
``visualizer.descriptors.roundDescriber.Describer.describe_narration()``: a summary
paragraph plus one paragraph per round, e.g. *"In the second round, people who voted for
Banana had their votes transferred to their next choice. Banana had the fewest votes and
was eliminated."* It is the same text spoken in the generated movies.

In all cases the description points to the table views, which carry the actual numbers.
The tables have captions, and the single-table summary adds visually hidden "Elected" and
"Eliminated" text to the cells whose status was previously shown by color only.

Moving between rounds
-------------------------------------

The intended screenreader workflow is: pick a round, then read what the chart says.

* Every round player has a labelled ``<select>`` for the round, native buttons for
previous, next and play, and a polite live region that announces "Round N of M" and the
round's narration whenever the round changes.
* The bar chart's description box above the chart is also a polite live region, and the
"Read a detailed explanation" link is a real button.

Keyboard support
-------------------------------------

* A "Skip to main content" link is the first focusable element on every page.
* The visualization tabs use the ARIA tabs pattern: ``role="tablist"``, ``role="tab"``
with ``aria-selected``, and ``role="tabpanel"``. Only the selected tab is in the tab
order; the arrow keys, Home and End move between tabs.

Testing
-------------------------------------

``visualizer/tests/testAccessibility.py`` checks the rendered markup, and
``visualizer/tests/testLiveBrowserHeadless.py`` exercises the tab keyboard navigation,
the chart descriptions and the round announcements in a headless browser.

Known gaps
-------------------------------------

* The Sankey diagram's nodes can be dragged with a mouse but not moved with the keyboard.
The table views carry the same data.
* The generated videos have spoken narration but no captions track yet.
* The pie chart is a third-party component; its animation-phase labels have low color
contrast.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Modules
:caption: Contents:

visualizer
accessibility
movie
graph
api
Expand Down
3 changes: 2 additions & 1 deletion scripts/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ elif [ "$CI_NODE_TOTAL" -eq 3 ]; then
./scripts/test-code-quality.sh

echo "Starting tests"
$RUN test visualizer.tests.testBallotpediaRestApi\
$RUN test visualizer.tests.testAccessibility\
visualizer.tests.testBallotpediaRestApi\
visualizer.tests.testDataTables\
visualizer.tests.testDataTablesRCTabFormat\
visualizer.tests.testDataTablesHeadlessBrowser\
Expand Down
34 changes: 33 additions & 1 deletion static/bargraph/barchart.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ function makeBarGraph(args) {
const textForWinner = args.textForWinner; // Eliminated, elected, etc
const doDimPrevRoundColors = args.doDimPrevRoundColors; // Desaturate previous rounds? No-op on noninteractive
const candidateSidecarData = args.candidateSidecarData; // Additional metadata about each candidate
const roundDescriptions = args.roundDescriptions || null; // Optional plain-English text per round, for screenreaders
const chartSummary = args.chartSummary || null; // Optional plain-English summary of the election, for screenreaders
const candidateVoteCounts = args.candidateVoteCounts; // List of dicts of candidate descriptions.
// Each dict has two keys:
// .candidate for the name,
Expand Down Expand Up @@ -78,12 +80,41 @@ function makeBarGraph(args) {
const viewboxWidth = width + margin.left + margin.right;
const viewboxHeight = height + margin.top + margin.bottom + paddingForVertical;

const svg = d3.select('#'+idOfContainer)
// The chart is exposed to screenreaders as a single image with a title and a
// description that follows the current round. The tables provide the raw numbers.
const svgTitleId = idOfContainer + "-title";
const svgDescId = idOfContainer + "-desc";
const svgRoot = d3.select('#'+idOfContainer)
.append("svg")
.attr("viewBox", "0 0 " + viewboxWidth + " " + viewboxHeight)
.attr("role", "img")
.attr("aria-labelledby", svgTitleId + " " + svgDescId);
svgRoot.append("title")
.attr("id", svgTitleId)
.text(isInteractive
? "Bar chart of each candidate's votes in the selected round"
: "Bar chart of each candidate's votes in every round");
const svgDesc = svgRoot.append("desc")
.attr("id", svgDescId)
.text(describeChart(numRounds - 1));
const svg = svgRoot
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");

function describeChart(round) {
let text = "";
if (isInteractive) {
text += "Showing round " + (round + 1) + " of " + numRounds + ". ";
if (roundDescriptions && roundDescriptions[round]) {
text += roundDescriptions[round] + " ";
}
} else if (chartSummary) {
text += chartSummary + " ";
}
text += "The same numbers are available in the table views.";
return text;
}

const surplusPatternId = "diagonalHatch"+idOfContainer;
const defs = svg.append("defs");
defs.append("pattern")
Expand Down Expand Up @@ -849,6 +880,7 @@ function makeBarGraph(args) {
transitionEachBarForRound();
transitionDataLabelsForRound();
transitionThresholdForRound();
svgDesc.text(describeChart(round));
};

// Enable the bootstrap tooltip
Expand Down
10 changes: 10 additions & 0 deletions static/bargraph/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ path.domain {
font-weight: bold;
}

/* The "Read a detailed explanation" button, styled as inline text */
.faq-link {
padding: 0;
border: 0;
font-size: inherit;
font-weight: bold;
line-height: inherit;
vertical-align: baseline;
}

.prev-next-button {
color: white;
}
Expand Down
23 changes: 22 additions & 1 deletion static/sankey/sankey-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApx
labels.text(function(d) { return "Round " + (d.round+1) });
}

function describeSankey() {
let text = "Each column is a round of counting, and each flow shows votes moving from an " +
"eliminated candidate to those voters' next choice. ";
if (typeof narration !== 'undefined' && narration && narration.summary) {
text += narration.summary + " ";
}
text += "The same numbers are available in the table views.";
return text;
}

function makeGraph(graph) {
// format variables
const formatNumber = d3.format(",.2f"); // two decimal places
Expand All @@ -145,9 +155,19 @@ function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApx
.style("width", getIdealWidth());

// append the svg object to the body of the page
const svg = d3.select("#sankey-body").append("svg")
// Exposed to screenreaders as a single described image; the tables have the numbers
const svgRoot = d3.select("#sankey-body").append("svg")
.attr("id", "sankey-svg")
.attr("viewBox", "0 0 " + makeViewboxSizeString(viewboxSize0, viewboxSize1))
.attr("role", "img")
.attr("aria-labelledby", "sankey-svg-title sankey-svg-desc");
svgRoot.append("title")
.attr("id", "sankey-svg-title")
.text("Sankey diagram of how votes moved between candidates in each round");
svgRoot.append("desc")
.attr("id", "sankey-svg-desc")
.text(describeSankey());
const svg = svgRoot
.append("g")
.attr("transform",
"translate(" + cmargin.left + "," + cmargin.top + ")");
Expand Down Expand Up @@ -266,6 +286,7 @@ function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApx
function makeTopBar(graph) {
const viewboxLeft = -tmargin.left
const topbarG = d3.select('#topbar').append("svg")
.attr("aria-hidden", "true")
.attr("viewBox", viewboxLeft + " 0 " + makeViewboxSizeString(tmarginLength, viewboxSize1))
.style("max-width", getIdealWidth())
.append("g")
Expand Down
18 changes: 18 additions & 0 deletions static/tabular/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ h3 {
padding-right: 1em;
}

/* The big number in each single-table-summary cell. Styled like the h3 it replaced
(1.2em bold, from common.css overriding bootstrap), but not a heading. */
.primaryLabel {
display: block;
font-size: 1.2em;
font-weight: 700;
line-height: 1.2;
margin-bottom: 0.5rem;
padding-left: 1em;
padding-right: 1em;
color: var(--rcv-ink);
}

/* Per-round and per-candidate table headings: h2 for document outline, sized like an h4 */
.round-heading {
font-size: 1.5rem;
}

#single-table-summary-table {
margin-bottom: 0;
}
36 changes: 36 additions & 0 deletions static/visualizer/round-player.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ function RoundPlayer({
totalRounds,
timeBetweenStepsMs,
firstStepHoldTimeMs,
narration, // Optional {summary, rounds: [...]}: plain-English text announced to screenreaders
announceRounds, // Announce round changes to screen readers (default true)
}) {
let isPlaying = false;
let currentStep = totalRounds - 1;
const shouldAnnounceRounds = announceRounds !== false;
// Imported from visualize-common.js
let stepTimeMs =
timeBetweenStepsMs || getTimeBetweenAnimationStepsMs(totalRounds);
Expand All @@ -32,8 +35,12 @@ function RoundPlayer({
navBtn.classList.add("round-player-hidden");
}

// The visible label is hidden on narrow screens, so name the button explicitly
navBtn.setAttribute("aria-label", isNext ? "Next round" : "Previous round");

const labelEl = document.createElement("span");
labelEl.classList.add("round-player-nav-label");
labelEl.setAttribute("aria-hidden", "true");
labelEl.innerText = isNext ? "Next" : "Back";
navBtn.appendChild(labelEl);

Expand All @@ -59,6 +66,7 @@ function RoundPlayer({

const select = document.createElement("select");
select.classList.add("round-player-select");
select.setAttribute("aria-label", "Round");
for (let round = 0; round < totalRounds; ++round) {
const opt = document.createElement("option");
opt.value = round;
Expand Down Expand Up @@ -87,9 +95,21 @@ function RoundPlayer({
return playBtn;
}

function createLiveRegion() {
// Announces the current round to screen readers when it changes
const liveEl = document.createElement("div");
liveEl.classList.add("round-player-live", "sr-only");
liveEl.setAttribute("role", "status");
liveEl.setAttribute("aria-live", "polite");
liveEl.setAttribute("aria-atomic", "true");
return liveEl;
}

function init() {
const playerEl = document.createElement("div");
playerEl.classList.add("round-player-container");
playerEl.setAttribute("role", "group");
playerEl.setAttribute("aria-label", "Round controls");

const wrapperEl = document.createElement("div");
wrapperEl.classList.add("round-player-wrapper");
Expand All @@ -98,11 +118,21 @@ function RoundPlayer({
wrapperEl.appendChild(createNavButton(true));

playerEl.appendChild(wrapperEl);

playerEl.appendChild(createPlayButton());

if (shouldAnnounceRounds) {
playerEl.appendChild(createLiveRegion());
}

container.appendChild(playerEl);
}

function announce(text) {
const liveEl = container.querySelector(".round-player-live");
if (liveEl) liveEl.innerText = text;
}

function changeStep(step) {
// This shouldn't get triggered, but short-circuit if out of bounds
if (step >= totalRounds || step < 0) {
Expand All @@ -127,6 +157,12 @@ function RoundPlayer({
}

onChange(step);

let announcement = `Round ${step + 1} of ${totalRounds}.`;
if (narration && narration.rounds[step]) {
announcement += " " + narration.rounds[step];
}
announce(announcement);
}

function setStep(step) {
Expand Down
Loading
Loading