From df6382de44fcf15f5c2d19a986bb7767bdef64a3 Mon Sep 17 00:00:00 2001 From: Jeffrey Lewis Date: Mon, 7 Sep 2026 23:31:35 +0000 Subject: [PATCH] Add touch-friendly region selection to the scatter plot (Phase 3, #450) Member selection on the DW-NOMINATE scatter plot is normally a drag- rectangle brush, which has no touch equivalent (D3 v3's brush stack is mouse-only). Before writing anything new, checked what already works on touch: tapping a single point to select it already works fine, since a tap synthesizes a click event the same as a mouse would, and the existing click-to-select-one-point handler (in both congress.js and voteCharts.js) just uses d3.mouse() on that click, which behaves identically regardless of input device. That's not what needed fixing. What's missing is a way to select an arbitrary rectangle of multiple members, which does need a genuine drag gesture. Added a "Select region" toggle button (visible only on coarse-pointer devices via a `(pointer: coarse)` media query -- mouse/trackpad users already have drag) that arms a two-tap mode: tap once for a corner, tap again for the opposite corner, and the code computes the same min/max rectangle a drag would have produced and applies it via the chart's own `.brush().extent(...).event(scb)` call, identical to what the existing single-point path already does. Tapping the toggle again while armed cancels without completing a selection. Applied identically to congress.js/congress.tpl (whole-chamber roster) and voteCharts.js/vote.tpl (single-vote breakdown), since both duplicate the same click-to-select scatter code. Verified with headless Chromium (Playwright), added as permanent checks: - Region-select via two taps produces the *exact same* member selection (by data-count) as a real mouse drag-brush between the same two NOMINATE coordinates, on both congress.js and voteCharts.js -- 178/178 and 100/100 members matched exactly - The toggle is hidden on desktop (pointer:fine) and visible on touch (iPhone 13 emulation) - Canceling mid-selection resets the button and doesn't apply a selection; a normal tap-to-select-one-point still works immediately afterward - Existing single-tap-select and mouse drag-brush behavior confirmed unchanged - Full existing 41-check suite still passes Also fixed a markup slip from an earlier edit pass on vote.tpl: the button insertion had replaced the `

` heading's closing tag instead of preserving it. Caught in review before commit -- browsers auto-close an unclosed `

` so it had no visible/functional effect (confirmed: every test above passed against the broken markup too), but it was still wrong and is fixed here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT --- static/css/base.css | 12 +++++++++++- static/js/congress.js | 40 +++++++++++++++++++++++++++++++++++++++- static/js/voteCharts.js | 40 +++++++++++++++++++++++++++++++++++++++- views/congress.tpl | 1 + views/vote.tpl | 1 + 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/static/css/base.css b/static/css/base.css index 0f4513ac8..3b6e1020f 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -573,7 +573,17 @@ span.party_header { padding-right: 20px; } .roster_header h4 { display:inline; } #memberList { columns: auto 4; list-style-type: none; overflow: auto; width: 100%; margin-bottom: 60px; } #memberTextList { margin-bottom: 40px; overflow-x: auto; } -.save_icon { margin-left: 5px; font-size: 22px; vertical-align: middle; cursor: pointer; } +.save_icon { margin-left: 5px; font-size: 22px; vertical-align: middle; cursor: pointer; } + +/* Only touch devices need a "select region" toggle -- mouse/trackpad + users already draw a selection box by dragging. */ +.touch-only-control { display: none; } +@media (pointer: coarse) { + .touch-only-control { display: inline-block; } +} +#regionSelectToggle { margin-left: 10px; font-size: 14px; } +#regionSelectToggle[aria-pressed="true"] { font-weight: bold; text-decoration: underline; } + #scatter-container { margin: 0 auto 10px auto; } #filterName { float: right; } .column1 { columns: 1 !important; width: 25% !important; } diff --git a/static/js/congress.js b/static/js/congress.js index e6bcd6867..52ecfa926 100644 --- a/static/js/congress.js +++ b/static/js/congress.js @@ -173,12 +173,50 @@ function nomPlot() decorateNominate(nominateScatterChart, resultCache); setScatterViewBox(nominateScatterChart); + // "Select region" mode for touch: tap two opposite corners to draw + // a selection rectangle, since there's no reliable drag gesture on + // touch to draw one directly (tap-to-select-one-point below + // already works fine on touch, since a tap synthesizes a click + // event same as a mouse would produce). + var regionSelectMode = false; + var regionSelectFirstCorner = null; + var regionSelectToggle = document.getElementById("regionSelectToggle"); + function setRegionSelectMode(on) { + regionSelectMode = on; + regionSelectFirstCorner = null; + if (regionSelectToggle) { + regionSelectToggle.setAttribute("aria-pressed", on ? "true" : "false"); + regionSelectToggle.textContent = on ? "Tap first corner…" : "Select region"; + } + } + if (regionSelectToggle) { + regionSelectToggle.addEventListener("click", function() { + setRegionSelectMode(!regionSelectMode); + }); + } + // Make brush box appear on click var scb = nominateScatterChart.select(".brush"); scb.on('click', function(){ - var extent = nominateScatterChart.brush().extent(); var x = nominateScatterChart.x().invert(d3.mouse(this)[0]), y = nominateScatterChart.y().invert(d3.mouse(this)[1]); + + if (regionSelectMode) { + if (!regionSelectFirstCorner) { + regionSelectFirstCorner = [x, y]; + regionSelectToggle.textContent = "Tap opposite corner…"; + return; + } + var x1 = regionSelectFirstCorner[0], y1 = regionSelectFirstCorner[1]; + nominateScatterChart.brush().extent([ + [Math.min(x1, x), Math.min(y1, y)], + [Math.max(x1, x), Math.max(y1, y)] + ]).event(scb); + setRegionSelectMode(false); + return; + } + + var extent = nominateScatterChart.brush().extent(); // Only draw box if there isn't one already there... if (extent[0][0]==extent[1][0] & extent[0][1]==extent[1][1]) { if (x*x + y*y <= 1) { diff --git a/static/js/voteCharts.js b/static/js/voteCharts.js index e508c8f2f..cdd6bbdfe 100644 --- a/static/js/voteCharts.js +++ b/static/js/voteCharts.js @@ -638,13 +638,51 @@ function drawWidgets(error, data, geodata, usaboundaries) nominateScatterChart.on("filtered", pollFilters); outVotes(); + // "Select region" mode for touch: tap two opposite corners to draw + // a selection rectangle, since there's no reliable drag gesture on + // touch to draw one directly (tap-to-select-one-point below + // already works fine on touch, since a tap synthesizes a click + // event same as a mouse would produce). + var regionSelectMode = false; + var regionSelectFirstCorner = null; + var regionSelectToggle = document.getElementById("regionSelectToggle"); + function setRegionSelectMode(on) { + regionSelectMode = on; + regionSelectFirstCorner = null; + if (regionSelectToggle) { + regionSelectToggle.setAttribute("aria-pressed", on ? "true" : "false"); + regionSelectToggle.textContent = on ? "Tap first corner…" : "Select region"; + } + } + if (regionSelectToggle) { + regionSelectToggle.addEventListener("click", function() { + setRegionSelectMode(!regionSelectMode); + }); + } + // Make brush box appear on click var scb = nominateScatterChart.select(".brush"); scb.on('click', function(){ var sizeOfBox = 0.03/2; - var extent = nominateScatterChart.brush().extent(); var x = nominateScatterChart.x().invert(d3.mouse(this)[0]), y = nominateScatterChart.y().invert(d3.mouse(this)[1]); + + if (regionSelectMode) { + if (!regionSelectFirstCorner) { + regionSelectFirstCorner = [x, y]; + regionSelectToggle.textContent = "Tap opposite corner…"; + return; + } + var x1 = regionSelectFirstCorner[0], y1 = regionSelectFirstCorner[1]; + nominateScatterChart.brush().extent([ + [Math.min(x1, x), Math.min(y1, y)], + [Math.max(x1, x), Math.max(y1, y)] + ]).event(scb); + setRegionSelectMode(false); + return; + } + + var extent = nominateScatterChart.brush().extent(); // Only draw box if there isn't one already there... if (extent[0][0]==extent[1][0] & extent[0][1]==extent[1][1]) { if (x*x + y*y <= 1) { diff --git a/views/congress.tpl b/views/congress.tpl index 6a37bc25f..50fbd5318 100644 --- a/views/congress.tpl +++ b/views/congress.tpl @@ -42,6 +42,7 @@ data-toggle="tooltip" data-position="bottom" data-html="true" title="Save Plot as PNG" onclick="javascript:saveSvgAsPng($('#scatter-chart > svg')[0],'plot_{{member_label}}_{{congress}}.png', {backgroundColor: 'white'}); return false;" > +

diff --git a/views/vote.tpl b/views/vote.tpl index 2cdf86df3..11ff34fee 100644 --- a/views/vote.tpl +++ b/views/vote.tpl @@ -81,6 +81,7 @@ data-toggle="tooltip" data-position="bottom" data-html="true" title="Save Plot as PNG"> Help: click and drag on the plot to focus on selected members. +