diff --git a/README.md b/README.md index 12b0ff1d..2b45fc8d 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/common/viewUtils.py b/common/viewUtils.py index 95306d5a..7c11de42 100644 --- a/common/viewUtils.py +++ b/common/viewUtils.py @@ -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 } diff --git a/docs/source/accessibility.rst b/docs/source/accessibility.rst new file mode 100644 index 00000000..e35e746b --- /dev/null +++ b/docs/source/accessibility.rst @@ -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 ```` 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. diff --git a/docs/source/index.rst b/docs/source/index.rst index d0fe27ce..ae049eed 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,6 +11,7 @@ Modules :caption: Contents: visualizer + accessibility movie graph api diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index a1937857..8691ed44 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -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\ diff --git a/static/bargraph/barchart.js b/static/bargraph/barchart.js index c2137b14..e5cb1d7f 100644 --- a/static/bargraph/barchart.js +++ b/static/bargraph/barchart.js @@ -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, @@ -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") @@ -849,6 +880,7 @@ function makeBarGraph(args) { transitionEachBarForRound(); transitionDataLabelsForRound(); transitionThresholdForRound(); + svgDesc.text(describeChart(round)); }; // Enable the bootstrap tooltip diff --git a/static/bargraph/style.css b/static/bargraph/style.css index 13dbb1af..f65f7400 100644 --- a/static/bargraph/style.css +++ b/static/bargraph/style.css @@ -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; } diff --git a/static/sankey/sankey-wrapper.js b/static/sankey/sankey-wrapper.js index d7e7edfd..f7ae6b0e 100644 --- a/static/sankey/sankey-wrapper.js +++ b/static/sankey/sankey-wrapper.js @@ -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 @@ -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 + ")"); @@ -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") diff --git a/static/tabular/style.css b/static/tabular/style.css index 33c7ee41..d4c43a5a 100644 --- a/static/tabular/style.css +++ b/static/tabular/style.css @@ -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; } diff --git a/static/visualizer/round-player.js b/static/visualizer/round-player.js index e705b7de..e615b39d 100644 --- a/static/visualizer/round-player.js +++ b/static/visualizer/round-player.js @@ -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); @@ -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); @@ -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; @@ -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"); @@ -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) { @@ -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) { diff --git a/static/visualizer/tabs-nonblocking.js b/static/visualizer/tabs-nonblocking.js index ca44626e..611bd266 100644 --- a/static/visualizer/tabs-nonblocking.js +++ b/static/visualizer/tabs-nonblocking.js @@ -20,14 +20,29 @@ function swapTabVisibility(oldTabName, newTabName) { document.getElementById(newTabId).style.display = 'block'; } +function tabLinkFor(tabName) { + return document.querySelector('a[data-toggle="changeviz"][href="#' + tabName + '"]'); +} + function goToTab(newTabName) { if (newTabName == currentTabName) return; + if (tabLinkFor(newTabName) == null) { + // Unknown #anchor: fall back to the default tab + newTabName = 'barchart'; + if (newTabName == currentTabName) return; + } // Update navigation if (currentTabName != null) { - $('a[data-toggle="changeviz"][href="#' + currentTabName + '"]').removeClass('selected-nav'); + const oldLink = tabLinkFor(currentTabName); + oldLink.classList.remove('selected-nav'); + oldLink.setAttribute('aria-selected', 'false'); + oldLink.setAttribute('tabindex', '-1'); } - $('a[data-toggle="changeviz"][href="#' + newTabName + '"]').addClass('selected-nav'); + const newLink = tabLinkFor(newTabName); + newLink.classList.add('selected-nav'); + newLink.setAttribute('aria-selected', 'true'); + newLink.removeAttribute('tabindex'); // Select tab via bootstrap swapTabVisibility(currentTabName, newTabName); @@ -43,6 +58,43 @@ function goToTab(newTabName) { } } +function visibleTabLinks() { + // Tabs hidden by the config (e.g. hidePie) are skipped when moving with the keyboard + return Array.from(document.querySelectorAll('a[data-toggle="changeviz"]')) + .filter(link => link.closest('li').offsetParent !== null); +} + +// Roving tabindex: only the selected tab is in the tab order, arrow keys move between tabs +function initializeTabKeyboardNavigation() { + const allLinks = document.querySelectorAll('a[data-toggle="changeviz"]'); + allLinks.forEach(link => { + if (!link.classList.contains('selected-nav')) { + link.setAttribute('tabindex', '-1'); + } + link.addEventListener('keydown', function(e) { + const links = visibleTabLinks(); + const index = links.indexOf(this); + let nextIndex = null; + if (e.key == 'ArrowRight' || e.key == 'ArrowDown') { + nextIndex = (index + 1) % links.length; + } else if (e.key == 'ArrowLeft' || e.key == 'ArrowUp') { + nextIndex = (index - 1 + links.length) % links.length; + } else if (e.key == 'Home') { + nextIndex = 0; + } else if (e.key == 'End') { + nextIndex = links.length - 1; + } + if (nextIndex === null) return; + e.preventDefault(); + const nextLink = links[nextIndex]; + const tabName = nextLink.getAttribute('href').substring(1); + goToTab(tabName); + history.pushState(null, null, '#' + tabName); + nextLink.focus(); + }); + }); +} + function loadTabFromTag() { // c/o https://stackoverflow.com/a/9393768/1057105 // Javascript to enable link to tab @@ -100,5 +152,6 @@ document.getElementById("make-interactive").addEventListener("click", function(e }); loadTabFromTag(); +initializeTabKeyboardNavigation(); hideTabsBasedOnConfig() window.addEventListener("hashchange", loadTabFromTag, false); diff --git a/static/visualizer/theme.css b/static/visualizer/theme.css index 90eeb176..18c2cc2f 100644 --- a/static/visualizer/theme.css +++ b/static/visualizer/theme.css @@ -50,6 +50,44 @@ html { scroll-padding-top: calc(var(--rcv-header-height) + 12px); } +/* Visually hidden, but still read by screen readers */ +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* "Skip to main content" link: only visible when focused via keyboard */ +.skip-link { + position: absolute; + top: 8px; + left: 8px; + z-index: 2000; + padding: 0.5rem 1rem; + background: var(--rcv-brand); + color: #fff; + border-radius: var(--rcv-radius); + font-family: var(--rcv-font-ui); + transform: translateY(-200%); + transition: transform 0.15s ease; +} +.skip-link:focus { + transform: translateY(0); + color: #fff; + outline: 2px solid var(--rcv-ink); + outline-offset: 2px; +} + +#maincontent:focus { + outline: none; +} + body { -webkit-font-smoothing: antialiased; } @@ -445,6 +483,14 @@ h1, h2, h3, h4, h5, h6 { padding-left: 0.6rem; } +.masthead .masthead-details { + font-size: 0.85rem; +} +.masthead .masthead-details a { + /* Links inside running text must not rely on color alone */ + text-decoration: underline; +} + /* ------------------------------------------------------------------ */ /* Footer */ /* ------------------------------------------------------------------ */ @@ -483,7 +529,7 @@ h1, h2, h3, h4, h5, h6 { } } -.site-footer h5 { +.site-footer h2 { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; diff --git a/static/visualizer/updates.css b/static/visualizer/updates.css new file mode 100644 index 00000000..893aa041 --- /dev/null +++ b/static/visualizer/updates.css @@ -0,0 +1,53 @@ +/* Recent updates page: a short, dated list of changes */ +.updates-page { + max-width: 720px; + margin: 0 auto; + padding: 3rem 1rem 4rem 1rem; + font-family: var(--rcv-font-ui); + line-height: 1.6; +} + +.updates-title { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.01em; + margin-bottom: 0.5rem; +} + +.updates-intro { + color: var(--rcv-muted); + margin-bottom: 2.5rem; +} + +.updates-month { + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--rcv-brand); + border-bottom: 1px solid var(--rcv-border); + padding-bottom: 0.5rem; + margin: 2.5rem 0 1.25rem 0; +} + +.updates-month:first-of-type { + margin-top: 0; +} + +.updates-post { + margin-bottom: 2.25rem; +} + +.updates-post h3 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.updates-post ul { + padding-left: 1.25rem; +} + +.updates-post li { + margin-bottom: 0.35rem; +} diff --git a/static/visualizer/visualize-ballotpedia.css b/static/visualizer/visualize-ballotpedia.css index 7d55b06b..2c3d34b2 100644 --- a/static/visualizer/visualize-ballotpedia.css +++ b/static/visualizer/visualize-ballotpedia.css @@ -1,4 +1,6 @@ #bp-header { + margin: 0; + line-height: 1.5; width: 100%; background-color: #4c4c4c; color: white; diff --git a/templates/bargraph/barchart-fixed-nonblocking.html b/templates/bargraph/barchart-fixed-nonblocking.html index f5f40a44..d0cbc592 100644 --- a/templates/bargraph/barchart-fixed-nonblocking.html +++ b/templates/bargraph/barchart-fixed-nonblocking.html @@ -21,7 +21,9 @@ isVertical: false, textForWinner: "{{ textForWinner }}", doDimPrevRoundColors: config.doDimPrevRoundColors, - candidateSidecarData: candidateSidecarData + candidateSidecarData: candidateSidecarData, + roundDescriptions: narration.rounds, + chartSummary: narration.summary }); } makeFixedGraph(); diff --git a/templates/bargraph/barchart-interactive-nonblocking.html b/templates/bargraph/barchart-interactive-nonblocking.html index 52c34bc1..edd122d3 100644 --- a/templates/bargraph/barchart-interactive-nonblocking.html +++ b/templates/bargraph/barchart-interactive-nonblocking.html @@ -80,7 +80,7 @@ .map(d => "<p class='faq-q'>" + d['question'] + "</p>" + "<p class='faq-a'>" + d['answer'] + "</p>") .reduce((accum, val) => accum + val); - document.getElementById(idOfFaqTextDiv).innerHTML = `<h3 class="faq-description-header">Round ${round + 1} Explanation</h3> ${text}`; + document.getElementById(idOfFaqTextDiv).innerHTML = `<h2 class="faq-description-header">Round ${round + 1} Explanation</h2> ${text}`; setTimeout(lockScrollbarIfNeeded, 0); // Needs to run after the DOM is updated } @@ -119,7 +119,9 @@ isVertical: false, textForWinner: "{{ textForWinner }}", doDimPrevRoundColors: config.doDimPrevRoundColors, - candidateSidecarData: candidateSidecarData + candidateSidecarData: candidateSidecarData, + roundDescriptions: narration.rounds, + chartSummary: narration.summary }); function sliderValueChangedCallback(round) { @@ -134,7 +136,10 @@ onChange: sliderValueChangedCallback, onPlay: function() { showTextOnRoundDescriber(humanFriendlySummary, false); - } + }, + narration: narration, + // The round description above the chart is already a live region + announceRounds: false }); // After creating the slider, make the default text the summary diff --git a/templates/bargraph/barchart-interactive.html b/templates/bargraph/barchart-interactive.html index da5af29b..d198debd 100644 --- a/templates/bargraph/barchart-interactive.html +++ b/templates/bargraph/barchart-interactive.html @@ -8,12 +8,12 @@ <div class="container-fluid"> <div class="row d-flex flex-column justify-content-left align-items-start"> <div class="round-description-container"> - <div id="round-description-wrapper" class="round-description-wrapper" role="alert"> + <div id="round-description-wrapper" class="round-description-wrapper" role="status" aria-live="polite" aria-atomic="true"> <div class="round-description-text"> <span id="bargraph-interactive-round-description"> </span> <span id="bargraph-interactive-why-button"> - <a role="button" tabindex="0" onclick="showFaqs()">Read a detailed explanation</a>. + <button type="button" class="btn btn-link faq-link" onclick="showFaqs()">Read a detailed explanation</button>. </span> </div> </div> diff --git a/templates/pie/pie-nonblocking.html b/templates/pie/pie-nonblocking.html index d34262ec..bd90eb67 100644 --- a/templates/pie/pie-nonblocking.html +++ b/templates/pie/pie-nonblocking.html @@ -41,6 +41,7 @@ totalRounds: {{ config.numRounds }}, onChange: updatePieChartRound, timeBetweenStepsMs: 3000, + narration: narration, }); function updatePieChartRound(sliderIndex) { diff --git a/templates/pie/pie.html b/templates/pie/pie.html index 8c740660..6ece89a2 100644 --- a/templates/pie/pie.html +++ b/templates/pie/pie.html @@ -5,5 +5,10 @@ <div id="pie-wrapper" class="shadow-wrapper"> <div id="pie-slider-container"></div> - <div id="pie-body" style="width: 100%; max-width: 800px; aspect-ratio: 1; margin: 0 auto;"></div> + <p id="pie-description" class="sr-only"> + Pie chart of each candidate's share of the votes in the selected round. + Use the round controls above to change rounds; the round is described as it changes. + The same numbers are available in the table views. + </p> + <div id="pie-body" style="width: 100%; max-width: 800px; aspect-ratio: 1; margin: 0 auto;" aria-describedby="pie-description"></div> </div> diff --git a/templates/tabular/tabular-by-candidate.html b/templates/tabular/tabular-by-candidate.html index ce94d4d0..99532e60 100644 --- a/templates/tabular/tabular-by-candidate.html +++ b/templates/tabular/tabular-by-candidate.html @@ -2,8 +2,9 @@ {% for candidateTabulation in tabularByCandidate.tabulation %} <div class="candidate-container" data-candidate-name="{{ candidateTabulation.name }}" data-is-winner="{{ candidateTabulation.isWinner|yesno:'true,false' }}"> - <h4 class="text-center"> {{ candidateTabulation.name }} </h4> + <h2 class="text-center round-heading"> {{ candidateTabulation.name }} </h2> <table class="shadow-wrapper table fixed-width-table"> + <caption class="sr-only">{{ candidateTabulation.name }}: what changed and total votes in each round</caption> <thead class="thead-light"> <tr> <th scope="col">Round</th> diff --git a/templates/tabular/tabular-by-round-interactive-nonblocking.html b/templates/tabular/tabular-by-round-interactive-nonblocking.html index 8fb7d161..9c516cea 100644 --- a/templates/tabular/tabular-by-round-interactive-nonblocking.html +++ b/templates/tabular/tabular-by-round-interactive-nonblocking.html @@ -19,6 +19,7 @@ container: document.getElementById("tabular-by-round-slider-container"), totalRounds: numRounds, onChange: showRound, + narration: narration, }); </script> diff --git a/templates/tabular/tabular-by-round-interactive.html b/templates/tabular/tabular-by-round-interactive.html index 4af15789..3891df9a 100644 --- a/templates/tabular/tabular-by-round-interactive.html +++ b/templates/tabular/tabular-by-round-interactive.html @@ -6,8 +6,9 @@ <div id="tabularByRoundInteractiveContainer"> {% for round in tabularByRoundInteractive.rounds %} <div id="tabular_round_container_{{ forloop.counter0 }}"> - <h4 class="mt-2 text-center">Round {{ forloop.counter }}</h4> + <h2 class="mt-2 text-center round-heading">Round {{ forloop.counter }}</h2> <table class="shadow-wrapper table fixed-width-table"> + <caption class="sr-only">Round {{ forloop.counter }}: what changed for each candidate, and their total votes</caption> <thead class="thead-light"> <tr> <th scope="col">Candidate</th> diff --git a/templates/tabular/tabular-by-round.html b/templates/tabular/tabular-by-round.html index 6db983b8..11b2ad69 100644 --- a/templates/tabular/tabular-by-round.html +++ b/templates/tabular/tabular-by-round.html @@ -6,6 +6,7 @@ <div class="responsive-table-wrapper"> <div id="tabularByRoundInteractiveContainer" class="responsive-table"> <table class="shadow-wrapper table fixed-width-table"> + <caption class="sr-only">Candidates eliminated and elected in each round</caption> <thead class="thead-light"> <tr> <th scope="col">Round</th> diff --git a/templates/tabular/tabular-candidate-by-round.html b/templates/tabular/tabular-candidate-by-round.html index 1747f2e3..b08c5977 100644 --- a/templates/tabular/tabular-candidate-by-round.html +++ b/templates/tabular/tabular-candidate-by-round.html @@ -5,6 +5,7 @@ <div class="shadow-wrapper responsive-table-wrapper" id="single-table-summary-wrapper"> <div class="responsive-table"> <table class="table" id="single-table-summary-table"> + <caption class="sr-only">Each candidate's votes in each round. Cells are shaded green once a candidate is elected and red once they are eliminated.</caption> <thead class="thead-light"> <tr> <th scope="col" class="col-auto">Candidate name </th> @@ -19,12 +20,15 @@ <tr> <th scope="row">{{ candidateTabulation.name }}</th> {% for round in candidateTabulation.eachRound %} - {% get_round_background_color candidateTabulation.name round as roundBg %} - <td style="background-color: {{ roundBg }}"> + {% get_round_cell_info candidateTabulation.name round as cellInfo %} + <td style="background-color: {{ cellInfo.color }}"> {% if round %} - <h3 class="primaryLabel">{{ round.primaryLabel }}</h3> + <span class="primaryLabel">{{ round.primaryLabel }}</span> {{ round.secondaryLabel }} {% endif %} + {% if cellInfo.status %} + <span class="sr-only">{{ cellInfo.status }}</span> + {% endif %} </td> {% endfor %} </tr> diff --git a/templates/visualizer/base.html b/templates/visualizer/base.html index 41c67e83..0ba4b7e2 100644 --- a/templates/visualizer/base.html +++ b/templates/visualizer/base.html @@ -12,6 +12,7 @@ </head> <body id="page-top" class="{% block bodyclass %}{% endblock %}"> + <a class="skip-link" href="#maincontent">Skip to main content</a> <!-- Site header / navigation --> <header class="site-header" id="mainNav"> <nav class="navbar container" aria-label="Main navigation"> @@ -21,23 +22,26 @@ </nav> </header> -<div id="maincontent"> +<main id="maincontent" tabindex="-1"> {% block maincontent %} {% endblock %} -</div> +</main> <!-- Footer --> <footer class="site-footer"> <div class="container"> <div class="site-footer-grid"> <div> - <h5>About RCVis</h5> + <h2>About RCVis</h2> <p>RCVis is open-source Ranked-Choice Voting Visualization software.</p> <p>Let's collaborate! To use these visualizations for your election, poll, or infographic, please get in touch: <a href="mailto:team@rcvis.com">team@rcvis.com</a>.</p> </div> <div> - <h5>Stay in touch</h5> + <h2>Stay in touch</h2> <ul class="site-footer-links"> + <li> + <a href="{% url 'updates' %}">Recent updates</a> + </li> <li> <a href="https://docs.google.com/forms/d/e/1FAIpQLSdwMw6xYNyxbZiEtuB0G9F7avQhkLY_jmlXjNOBPA01qR4y3g/viewform?usp=sf_link" target="_blank" rel="noopener">Send feedback</a> </li> @@ -53,7 +57,7 @@ <h5>Stay in touch</h5> </ul> </div> <div> - <h5>Newsletter</h5> + <h2>Newsletter</h2> <p>Occasional updates about new features and elections.</p> <!-- Begin Mailchimp Signup Form --> <div id="mc_embed_signup"> diff --git a/templates/visualizer/common-visualizer-header.html b/templates/visualizer/common-visualizer-header.html index 5bd9868f..59631392 100644 --- a/templates/visualizer/common-visualizer-header.html +++ b/templates/visualizer/common-visualizer-header.html @@ -54,6 +54,8 @@ // Data from python var humanFriendlyEventsPerRound = {{humanFriendlyEventsPerRound|safe}}; var humanFriendlySummary = {{humanFriendlySummary|safe}}; + // Plain-English narration of the whole election, for screenreaders: {summary, rounds: [...]} + var narration = {{narrationJson|safe}}; var candidateSidecarData = {{ candidateSidecarData|safe }}; </script> diff --git a/templates/visualizer/index.html b/templates/visualizer/index.html index cda10004..8fdf3ea9 100644 --- a/templates/visualizer/index.html +++ b/templates/visualizer/index.html @@ -28,6 +28,9 @@ <li class="nav-item"> <a class="nav-link" href="/upload.html">Create A Visualization</a> </li> + <li class="nav-item"> + <a class="nav-link" href="{% url 'updates' %}">Recent Updates</a> + </li> </ul> </div> {% endblock %} @@ -280,26 +283,16 @@ <h2 class="page-section-heading text-center">Future Development</h2> </p> <ol> <li> - <strong>Accessibility</strong>: - RCVis is not screenreader-friendly, though we have an undocumented audio captions for each visualization. - We'd like to make these audio captions widely available, as well as making all RCVis visualizations screenreader-friendly. - </li> - <li> - <strong>ES&S Data</strong>: - We'd like to be able to upload and visualize data from Election Systems & Software voting machines. - </li> - <li> - <strong>Infrastructure Updates</strong>: - We'd like to use enable autoscaling to better support the influx of users and uploads on election night, as well as other infrastructure - updates for stability and speed. + <strong>User Dashboard</strong>: + We'd like to provide users a method for managing their own visualizations, including modifying them, deleting them, and marking them as private. </li> <li> <strong>"Blue Check" Verification</strong>: We'd like a system for displaying when a visualization hosted on RCVis has been vetted by an election administrator. </li> <li> - <strong>User Dashboard</strong>: - We'd like to provide users a method for managing their own visualizations, including modifying them, deleting them, and marking them as private. + <strong>ES&S Data</strong>: + We'd like to be able to upload and visualize data from Election Systems & Software voting machines. </li> </ol> </div> diff --git a/templates/visualizer/updates.html b/templates/visualizer/updates.html new file mode 100644 index 00000000..20d02dcc --- /dev/null +++ b/templates/visualizer/updates.html @@ -0,0 +1,43 @@ +{% extends "visualizer/base.html" %} +{% load static %} +{% load compress %} + +{% block header %} + {% compress css file %} + <link rel="stylesheet" href="{% static "visualizer/updates.css" %}"> + {% endcompress %} + + <title>Recent Updates: RCVis + +{% endblock %} + +{% block maincontent %} +
+

Recent Updates

+

+ RCVis changelog +

+ +

September 2026

+ +
+

Screenreader and keyboard support

+

+ Every visualization now works with screenreaders and without a mouse. This applies to + the full RCVis pages and to visualizations embedded on other sites. +

+ +
+ +
+

A new look for RCVis

+

+ RCVis has a refreshed visual design. The charts and tables themselves have not changed, just cleaner formatting throughout. +

+
+
+{% endblock %} diff --git a/templates/visualizer/visualize-ballotpedia.html b/templates/visualizer/visualize-ballotpedia.html index a7c7d228..41358c4c 100644 --- a/templates/visualizer/visualize-ballotpedia.html +++ b/templates/visualizer/visualize-ballotpedia.html @@ -15,8 +15,8 @@
-
{{title}}
-
+

{{title}}

+
{% include "bargraph/barchart-interactive.html" %}
@@ -35,18 +35,18 @@
Total Votes: {{ numVotesFirstRound }}
-
- +
{% include "visualizer/common-visualizer-nonblocking.html" %} diff --git a/templates/visualizer/visualize-embedded.html b/templates/visualizer/visualize-embedded.html index 84c9260b..4c899334 100644 --- a/templates/visualizer/visualize-embedded.html +++ b/templates/visualizer/visualize-embedded.html @@ -10,7 +10,8 @@
-
+
+

{{ title }}

{% if vistype == "sankey" %} {% include "sankey/sankey.html" %} {% elif vistype == "pie" %} @@ -30,18 +31,18 @@ {% else %}

Invalid visualization type requested. Don't expect this error? Contact team@rcvis.com for help.

{% endif %} -
- +
{% include "visualizer/common-visualizer-nonblocking.html" %} diff --git a/templates/visualizer/visualize.html b/templates/visualizer/visualize.html index a1f4c844..1df53281 100644 --- a/templates/visualizer/visualize.html +++ b/templates/visualizer/visualize.html @@ -33,51 +33,51 @@ {% block bodyclass %}vis-page{% endblock %} {% block navlinks %} -