From f14099e064e8b8a4f1d9a43c5a560fa8f643f275 Mon Sep 17 00:00:00 2001 From: Jeffrey Lewis Date: Mon, 7 Sep 2026 21:09:18 +0000 Subject: [PATCH 1/4] Scale the scatter plot and map to fit their container instead of overflowing Retry of the approach from the earlier, reverted #454: give every DC.js chart's SVG a viewBox matching its native pixel size, then let CSS (.dc-chart > svg { width:100%; height:auto }) shrink the box to fit its container -- the chart's internal coordinate system is unchanged, so D3's mouse/brush math (which resolves screen coordinates through the SVG's own transform) keeps working at any scale. What actually broke last time: this bundled dc.js predates per-chart renderlet callbacks. dc.renderlet(fn) is a page-global hook -- dc.renderAll() and dc.redrawAll() invoke it as dc._renderlet(group), passing the chart-group name (usually undefined), not a chart instance. The previous attempt treated that argument as a chart and called `chart.svg`, throwing a TypeError on every single render/redraw call, which silently aborted whatever ran right after dc.renderAll() (decorateNominate(), which draws the scatter plot's axes/shading) and left every chart's SVG without a viewBox at all, so the new width:100% CSS just clipped it instead of scaling it. Fixed by reading the actual chart list via dc.chartRegistry.list(group) -- the same lookup dc.renderAll/redrawAll use internally -- instead of misreading the argument as a chart. Applied to all six chart-init files (congress.js, voteCharts.js, party.js, committee.js, personIdeology.js, partyGlance.js) since dc.renderlet is a single global hook, not per-page. Also carries over the container-level responsive fixes from the original #454 (scatter/map container sizing, #memberList, #memberTextList, .loadVotes, tooltip width, #geoMap #map-chart made block instead of inline-block to avoid an ambiguous width:100%-in-shrink-to-fit case), and wraps #voteList in a scrollable .voteListScroll container. And keeps the mapPanZoom.js dimX=850 fix (was 890, didn't match mapChart's own width(850) -- would show as a jump on first zoom now that the map gets a real baseline viewBox on load instead of none at all). This time verified against the actual running app with headless Chromium (Playwright) rather than a syntax check and HTTP status codes -- 30 checks across congress/vote/party/person pages at both 390px and 1280px: - No console or page errors on load, and none introduced by interacting with the charts (clicking to brush-select on the scatter plot, clicking a map district to filter) - decorateNominate's output (axes, yea/nay cutline labels on an actual vote, shaded heatmap) is present after the fix -- this is exactly what silently broke last time - Map districts have real, finite geometry (not clipped to nothing) - Both SVGs' rendered width fits their container at both viewport widths - Click-to-brush-select and click-a-district-to-filter both still produce the expected selection filter bar - Resizing the viewport mid-session doesn't break the chart - The sticky vote-count chart (#455) and single-column vote list (#456) fixes from the last two PRs are still intact Also found, but did NOT fix (pre-existing on unmodified master, unrelated to this change, confirmed by reproducing it before touching any code): clicking a map district to filter the vote logs a console error from nominateHeatMap.js's cutline-arc path string construction ("Expected arc flag") for some filtered subsets -- looks like a NaN produced by a division edge case in the cutline math for lopsided/small subsets. Separate bug, separate fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT --- static/css/base.css | 38 ++++++++++++++++++++++++++++++------- static/js/committee.js | 16 ++++++++++++++++ static/js/congress.js | 16 ++++++++++++++++ static/js/mapPanZoom.js | 2 +- static/js/party.js | 16 ++++++++++++++++ static/js/partyGlance.js | 16 ++++++++++++++++ static/js/personIdeology.js | 16 ++++++++++++++++ static/js/voteCharts.js | 18 ++++++++++++++++++ views/vote.tpl | 4 +++- 9 files changed, 133 insertions(+), 9 deletions(-) diff --git a/static/css/base.css b/static/css/base.css index a38a1573b..1ab191142 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -91,12 +91,14 @@ button.link-button:focus { } #scatter-container { - width: 890px; + width: 100%; + max-width: 890px; height: 425px; position: relative; + overflow-x: auto; } -#scatter-bg, +#scatter-bg, #scatter-chart { width: 100%; height: 100%; @@ -105,6 +107,13 @@ button.link-button:focus { left: 0; } +#scatter-chart > svg, +#svg-bg { + display: block; + width: 100%; + height: auto; +} + #scatter-chart { z-index: 10; } @@ -187,6 +196,20 @@ button.link-button:focus { /* Maps and charts */ +.dc-chart { + max-width: 100%; + overflow-x: auto; +} + +/* Scale the chart's own SVG to fit its container (viewBox is added at + render time, see the dc.renderlet hook in each chart's JS) rather than + just letting it overflow into a scrollbar. */ +.dc-chart > svg { + display: block; + width: 100%; + height: auto; +} + .dc-chart g.row text { fill: black; } @@ -269,11 +292,11 @@ line.cutline { .tooltip > .tooltip-inner { opacity: 0.9; } .tooltip > .tooltip-arrow { opacity: 0.9; } -.left-tooltip + .tooltip > .tooltip-inner { text-align:left; min-width:400px;} +.left-tooltip + .tooltip > .tooltip-inner { text-align:left; width:400px; max-width:calc(100vw - 40px); } #memberList { - min-width: 800px; + min-width: 0; } ul#memberList.geography @@ -549,7 +572,7 @@ span.party_header { padding-right: 20px; } .roster_header { padding-bottom: 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; } +#memberTextList { margin-bottom: 40px; overflow-x: auto; } .save_icon { margin-left: 5px; font-size: 22px; vertical-align: middle; cursor: pointer; } #scatter-container { margin: 0 auto 10px auto; } #filterName { float: right; } @@ -568,7 +591,7 @@ span.party_header { padding-right: 20px; } #geoMap h4 { float: left; clear: none; vertical-align: middle; } #geoMap .glyphicon-save { margin-left: 5px; font-size: 18px; vertical-align: middle; cursor: pointer; } #geoMap .noteText { margin-left: 5px; width: 22px; vertical-align: middle; } -#geoMap #map-chart { margin-top: 10px; padding: 10px; vertical-align: bottom; } +#geoMap #map-chart { display: block; margin-top: 10px; padding: 10px; vertical-align: bottom; } #geoMap #map-chart #zoomIn { position: absolute; left: 25px; top: 40px; width: 30px; height: 30px; } #geoMap #map-chart #zoomOut { position: absolute; left: 25px; top: 80px; width: 30px; height: 30px; } #geoMap #map-chart #suppressMapControls { display: none; } @@ -586,6 +609,7 @@ span.party_header { padding-right: 20px; } #selectionFilterBar #sparse-selection { display: none; } .sortHeader { text-align:middle; padding-top: 3px; } .voteHeader { font-size: 19px; float: left; padding-right: 30px; text-align: middle; } +.voteListScroll { overflow-x: auto; } #voteList { margin-top: 15px; width: 100%; min-width: 1100px; } /* voteTable.js picks 1-4 columns based on how many members are listed @@ -704,7 +728,7 @@ h5.congSelector small { padding-left: 10px; } .bottomPad { padding-bottom: 20px; } .member_flag { width: 20px; vertical-align: middle; } .personSearch { padding-top: 10px; padding-bottom: 10px; clear: both; } -.loadVotes { float: right; padding-top: 12px; min-width: 400px; width: 400px; } +.loadVotes { float: right; padding-top: 12px; width: 400px; max-width: 100%; } .member_vote_load { position: fixed; top: 50%; diff --git a/static/js/committee.js b/static/js/committee.js index a397b805b..53ac3f02c 100644 --- a/static/js/committee.js +++ b/static/js/committee.js @@ -13,6 +13,22 @@ var dimChart, sizeChart; var committeeData = null; var committeePartyInfo = null; var congressLookup = {}; + +// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in +// base.css) can scale it to fit its container instead of overflowing it. +// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke +// it as dc._renderlet(group), passing the chart-group name (not a chart), +// so look the actual charts up via dc.chartRegistry, the same way dc.js +// itself does internally. +dc.renderlet(function(group) { + dc.chartRegistry.list(group).forEach(function(chart) { + if(!chart.svg) return; + var svg = chart.svg(); + if(!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); + }); +}); var currentSort = 'name'; var opacityTimer; var resultCache = null; diff --git a/static/js/congress.js b/static/js/congress.js index d88be8adb..2023dc743 100644 --- a/static/js/congress.js +++ b/static/js/congress.js @@ -7,6 +7,22 @@ var nominateScatterChart = dc.scatterPlot("#scatter-chart"); var baseTip = d3.select("body").append("div").attr("class", "d3-tip").style("visibility","hidden").attr("id","mapTooltip"); var eW; +// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in +// base.css) can scale it to fit its container instead of overflowing it. +// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke +// it as dc._renderlet(group), passing the chart-group name (not a chart), +// so look the actual charts up via dc.chartRegistry, the same way dc.js +// itself does internally. +dc.renderlet(function(group) { + dc.chartRegistry.list(group).forEach(function(chart) { + if(!chart.svg) return; + var svg = chart.svg(); + if(!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); + }); +}); + // From stackoverflow response, who borrowed it from Shopify--simple ordinal suffix. function getGetOrdinal(n) { var s=["th","st","nd","rd"], diff --git a/static/js/mapPanZoom.js b/static/js/mapPanZoom.js index 429590048..edb6cd2f6 100644 --- a/static/js/mapPanZoom.js +++ b/static/js/mapPanZoom.js @@ -2,7 +2,7 @@ let maxZoom = 20; // Set this to override max/min zoom let minZoom = 0.25; // Set this to override max/min zoom let incrementZoom = 0.25; let zoom = 1; -let dimX = 890; +let dimX = 850; // Matches mapChart.width(850) in voteCharts.js let dimY = 500; let panX = 0; let panY = 0; diff --git a/static/js/party.js b/static/js/party.js index a2bbe7e76..c10224d45 100644 --- a/static/js/party.js +++ b/static/js/party.js @@ -16,6 +16,22 @@ var mapTopo, stateTopo; var opacityTimer; var globalPartyName, globalPartyColorName; +// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in +// base.css) can scale it to fit its container instead of overflowing it. +// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke +// it as dc._renderlet(group), passing the chart-group name (not a chart), +// so look the actual charts up via dc.chartRegistry, the same way dc.js +// itself does internally. +dc.renderlet(function(group) { + dc.chartRegistry.list(group).forEach(function(chart) { + if(!chart.svg) return; + var svg = chart.svg(); + if(!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); + }); +}); + function congYear(num) { return [1787 + 2 * num, 1788 + 2 * num]; } // From stackoverflow response, who borrowed it from Shopify--simple ordinal diff --git a/static/js/partyGlance.js b/static/js/partyGlance.js index 1fc087d25..0bdf0ab5d 100644 --- a/static/js/partyGlance.js +++ b/static/js/partyGlance.js @@ -3,6 +3,22 @@ /* jshint globalstrict: true */ /* global dc,d3,crossfilter,colorbrewer,queue */ +// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in +// base.css) can scale it to fit its container instead of overflowing it. +// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke +// it as dc._renderlet(group), passing the chart-group name (not a chart), +// so look the actual charts up via dc.chartRegistry, the same way dc.js +// itself does internally. +dc.renderlet(function(group) { + dc.chartRegistry.list(group).forEach(function(chart) { + if(!chart.svg) return; + var svg = chart.svg(); + if(!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); + }); +}); + // From stackoverflow response, who borrowed it from Shopify--simple ordinal // suffix. function getGetOrdinal(n) { diff --git a/static/js/personIdeology.js b/static/js/personIdeology.js index bd16d1a70..bf64068e9 100644 --- a/static/js/personIdeology.js +++ b/static/js/personIdeology.js @@ -1,3 +1,19 @@ +// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in +// base.css) can scale it to fit its container instead of overflowing it. +// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke +// it as dc._renderlet(group), passing the chart-group name (not a chart), +// so look the actual charts up via dc.chartRegistry, the same way dc.js +// itself does internally. +dc.renderlet(function(group) { + dc.chartRegistry.list(group).forEach(function(chart) { + if(!chart.svg) return; + var svg = chart.svg(); + if(!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); + }); +}); + $("#congSelector").change(reloadIdeology); $(".nav-tabs > li > a").click(switchTab); diff --git a/static/js/voteCharts.js b/static/js/voteCharts.js index a5fabb8eb..26d25948f 100644 --- a/static/js/voteCharts.js +++ b/static/js/voteCharts.js @@ -10,6 +10,24 @@ var nominateScatterChart = dc.scatterPlot("#scatter-chart"); var globalPartyDimension = null; var globalData; +// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in +// base.css) can scale it to fit its container instead of overflowing it. +// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke +// it as dc._renderlet(group), passing the chart-group name (not a chart), +// so look the actual charts up via dc.chartRegistry, the same way dc.js +// itself does internally. The viewBox is only set once (skipped if one is +// already present), so this doesn't fight with mapChart's own pan/zoom +// viewBox management in mapPanZoom.js. +dc.renderlet(function(group) { + dc.chartRegistry.list(group).forEach(function(chart) { + if(!chart.svg) return; + var svg = chart.svg(); + if(!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); + }); +}); + // Makes the bootstrap tooltip run for votes from before states were contiguous. $(document).ready(function(){$('[data-toggle="tooltip"]').tooltip();}); diff --git a/views/vote.tpl b/views/vote.tpl index 26a482c8d..871459c1a 100644 --- a/views/vote.tpl +++ b/views/vote.tpl @@ -130,7 +130,9 @@ ) -
+
+
+
From dc765bd3119416f29549a88228cd7f60bfe255dd Mon Sep 17 00:00:00 2001 From: Jeffrey Lewis Date: Mon, 7 Sep 2026 21:22:49 +0000 Subject: [PATCH 2/4] Fix two regressions from the previous commit's over-broad chart scaling Both reported after the last push, both real: 1. Map pushed right with its eastern edge (Maine) clipped. The previous commit hardcoded the map's viewBox to "0 0 850 500" on the assumption it would match mapChart.width(850).height(500) exactly. It doesn't -- the actual rendered path geometry spans roughly x:31-866, y:11-497, so content past x=850 was clipped while the unused 0-31 margin on the left showed as blank space. Fixed by computing the viewBox from the real union of all rendered path bounding boxes (setMapContentViewBox in voteCharts.js) instead of guessing from the configured width/height. Also reverted the mapPanZoom.js dimX 890->850 change from the previous commit -- it was based on the same flawed "should match width()" assumption, and since neither value actually matches the true content bounds, reverting to the original avoids changing pan/zoom behavior that isn't part of today's fix. 2. The vote-count sidebar bar chart (#party-chart) got noticeably smaller text on desktop. Root cause: the previous commit's dc.renderlet hook was left registered globally in voteCharts.js even after switching to the targeted setScatterViewBox/setMapContentViewBox calls, so it was still adding a viewBox to *every* dc.js chart on the page, including #party-chart. That chart's native width (280px) is wider than its sidebar column (~195px), which was never a problem before -- it just rendered at native size, slightly overflowing unnoticed. Once it had a viewBox, the width:100% CSS scaled the whole thing (text included) down to fit the narrow column. Removed the leftover global hook entirely, along with the generic .dc-chart CSS scaling rule; only #scatter-chart and #map-chart get this treatment now; the other four chart-init files (party.js, committee.js, personIdeology.js, partyGlance.js) are back to their pre-mobile-work state. Verified with headless Chromium against the actual running app (34 checks, up from 30): all previous checks still pass, plus new checks confirming the map's rendered content stays fully inside its viewBox at both viewport widths with only a few pixels of intentional padding, and that #party-chart is no longer scaled (no viewBox, renders at its native 280px regardless of viewport). Visually confirmed via screenshots at 1280px and 390px -- Maine fully visible, no left margin, bar chart legend text back to its original size. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT --- static/css/base.css | 16 ++++++------- static/js/committee.js | 16 ------------- static/js/congress.js | 17 +------------- static/js/decorate.js | 14 ++++++++++++ static/js/mapPanZoom.js | 2 +- static/js/party.js | 16 ------------- static/js/partyGlance.js | 16 ------------- static/js/personIdeology.js | 16 ------------- static/js/voteCharts.js | 45 ++++++++++++++++++++++++------------- 9 files changed, 53 insertions(+), 105 deletions(-) diff --git a/static/css/base.css b/static/css/base.css index 1ab191142..1bd272993 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -196,15 +196,15 @@ button.link-button:focus { /* Maps and charts */ -.dc-chart { - max-width: 100%; - overflow-x: auto; -} -/* Scale the chart's own SVG to fit its container (viewBox is added at - render time, see the dc.renderlet hook in each chart's JS) rather than - just letting it overflow into a scrollbar. */ -.dc-chart > svg { +/* Scale the scatter plot and map's own SVGs to fit their container + (viewBox is added at render time, see congress.js/voteCharts.js) + instead of overflowing it. Deliberately scoped to just these two -- + applying this to every .dc-chart also shrank small sidebar charts + like #party-chart, which don't have a too-wide-for-mobile problem and + were previously rendered at native (legible) size regardless of their + column width. */ +#map-chart > svg { display: block; width: 100%; height: auto; diff --git a/static/js/committee.js b/static/js/committee.js index 53ac3f02c..a397b805b 100644 --- a/static/js/committee.js +++ b/static/js/committee.js @@ -13,22 +13,6 @@ var dimChart, sizeChart; var committeeData = null; var committeePartyInfo = null; var congressLookup = {}; - -// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in -// base.css) can scale it to fit its container instead of overflowing it. -// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke -// it as dc._renderlet(group), passing the chart-group name (not a chart), -// so look the actual charts up via dc.chartRegistry, the same way dc.js -// itself does internally. -dc.renderlet(function(group) { - dc.chartRegistry.list(group).forEach(function(chart) { - if(!chart.svg) return; - var svg = chart.svg(); - if(!svg || !svg.node() || svg.attr("viewBox")) return; - svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) - .attr("preserveAspectRatio", "xMidYMid meet"); - }); -}); var currentSort = 'name'; var opacityTimer; var resultCache = null; diff --git a/static/js/congress.js b/static/js/congress.js index 2023dc743..e6bcd6867 100644 --- a/static/js/congress.js +++ b/static/js/congress.js @@ -7,22 +7,6 @@ var nominateScatterChart = dc.scatterPlot("#scatter-chart"); var baseTip = d3.select("body").append("div").attr("class", "d3-tip").style("visibility","hidden").attr("id","mapTooltip"); var eW; -// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in -// base.css) can scale it to fit its container instead of overflowing it. -// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke -// it as dc._renderlet(group), passing the chart-group name (not a chart), -// so look the actual charts up via dc.chartRegistry, the same way dc.js -// itself does internally. -dc.renderlet(function(group) { - dc.chartRegistry.list(group).forEach(function(chart) { - if(!chart.svg) return; - var svg = chart.svg(); - if(!svg || !svg.node() || svg.attr("viewBox")) return; - svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) - .attr("preserveAspectRatio", "xMidYMid meet"); - }); -}); - // From stackoverflow response, who borrowed it from Shopify--simple ordinal suffix. function getGetOrdinal(n) { var s=["th","st","nd","rd"], @@ -187,6 +171,7 @@ function nomPlot() dc.filterAll(); dc.renderAll(); decorateNominate(nominateScatterChart, resultCache); + setScatterViewBox(nominateScatterChart); // Make brush box appear on click var scb = nominateScatterChart.select(".brush"); diff --git a/static/js/decorate.js b/static/js/decorate.js index 4df87829b..12ca8f259 100644 --- a/static/js/decorate.js +++ b/static/js/decorate.js @@ -1,6 +1,20 @@ var isDoingSelect=0; var delayUpdateToolip; +// Give the scatter plot's SVG a viewBox matching its configured drawing +// area, so CSS (#scatter-chart > svg in base.css) can scale the whole +// thing to fit its container instead of overflowing it. decorateNominate's +// own layout (axes, margins, heatmap) is computed directly from the +// chart's width()/height(), so those are exactly its true content bounds +// -- call this after decorateNominate() has run so the viewBox covers the +// decoration too, not just the scatter points dc.js draws on its own. +function setScatterViewBox(chart) { + var svg = chart.svg(); + if (!svg || !svg.node() || svg.attr("viewBox")) return; + svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) + .attr("preserveAspectRatio", "xMidYMid meet"); +} + /* Add sponsor circle */ diff --git a/static/js/mapPanZoom.js b/static/js/mapPanZoom.js index edb6cd2f6..429590048 100644 --- a/static/js/mapPanZoom.js +++ b/static/js/mapPanZoom.js @@ -2,7 +2,7 @@ let maxZoom = 20; // Set this to override max/min zoom let minZoom = 0.25; // Set this to override max/min zoom let incrementZoom = 0.25; let zoom = 1; -let dimX = 850; // Matches mapChart.width(850) in voteCharts.js +let dimX = 890; let dimY = 500; let panX = 0; let panY = 0; diff --git a/static/js/party.js b/static/js/party.js index c10224d45..a2bbe7e76 100644 --- a/static/js/party.js +++ b/static/js/party.js @@ -16,22 +16,6 @@ var mapTopo, stateTopo; var opacityTimer; var globalPartyName, globalPartyColorName; -// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in -// base.css) can scale it to fit its container instead of overflowing it. -// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke -// it as dc._renderlet(group), passing the chart-group name (not a chart), -// so look the actual charts up via dc.chartRegistry, the same way dc.js -// itself does internally. -dc.renderlet(function(group) { - dc.chartRegistry.list(group).forEach(function(chart) { - if(!chart.svg) return; - var svg = chart.svg(); - if(!svg || !svg.node() || svg.attr("viewBox")) return; - svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) - .attr("preserveAspectRatio", "xMidYMid meet"); - }); -}); - function congYear(num) { return [1787 + 2 * num, 1788 + 2 * num]; } // From stackoverflow response, who borrowed it from Shopify--simple ordinal diff --git a/static/js/partyGlance.js b/static/js/partyGlance.js index 0bdf0ab5d..1fc087d25 100644 --- a/static/js/partyGlance.js +++ b/static/js/partyGlance.js @@ -3,22 +3,6 @@ /* jshint globalstrict: true */ /* global dc,d3,crossfilter,colorbrewer,queue */ -// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in -// base.css) can scale it to fit its container instead of overflowing it. -// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke -// it as dc._renderlet(group), passing the chart-group name (not a chart), -// so look the actual charts up via dc.chartRegistry, the same way dc.js -// itself does internally. -dc.renderlet(function(group) { - dc.chartRegistry.list(group).forEach(function(chart) { - if(!chart.svg) return; - var svg = chart.svg(); - if(!svg || !svg.node() || svg.attr("viewBox")) return; - svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) - .attr("preserveAspectRatio", "xMidYMid meet"); - }); -}); - // From stackoverflow response, who borrowed it from Shopify--simple ordinal // suffix. function getGetOrdinal(n) { diff --git a/static/js/personIdeology.js b/static/js/personIdeology.js index bf64068e9..bd16d1a70 100644 --- a/static/js/personIdeology.js +++ b/static/js/personIdeology.js @@ -1,19 +1,3 @@ -// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in -// base.css) can scale it to fit its container instead of overflowing it. -// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke -// it as dc._renderlet(group), passing the chart-group name (not a chart), -// so look the actual charts up via dc.chartRegistry, the same way dc.js -// itself does internally. -dc.renderlet(function(group) { - dc.chartRegistry.list(group).forEach(function(chart) { - if(!chart.svg) return; - var svg = chart.svg(); - if(!svg || !svg.node() || svg.attr("viewBox")) return; - svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) - .attr("preserveAspectRatio", "xMidYMid meet"); - }); -}); - $("#congSelector").change(reloadIdeology); $(".nav-tabs > li > a").click(switchTab); diff --git a/static/js/voteCharts.js b/static/js/voteCharts.js index 26d25948f..12d654534 100644 --- a/static/js/voteCharts.js +++ b/static/js/voteCharts.js @@ -10,23 +10,33 @@ var nominateScatterChart = dc.scatterPlot("#scatter-chart"); var globalPartyDimension = null; var globalData; -// Give every DC.js chart's SVG a viewBox so CSS (.dc-chart > svg in -// base.css) can scale it to fit its container instead of overflowing it. -// dc.renderlet is a page-global hook: dc.renderAll()/dc.redrawAll() invoke -// it as dc._renderlet(group), passing the chart-group name (not a chart), -// so look the actual charts up via dc.chartRegistry, the same way dc.js -// itself does internally. The viewBox is only set once (skipped if one is -// already present), so this doesn't fight with mapChart's own pan/zoom -// viewBox management in mapPanZoom.js. -dc.renderlet(function(group) { - dc.chartRegistry.list(group).forEach(function(chart) { - if(!chart.svg) return; - var svg = chart.svg(); - if(!svg || !svg.node() || svg.attr("viewBox")) return; - svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) - .attr("preserveAspectRatio", "xMidYMid meet"); +// Give the map's SVG a viewBox matching its actual rendered content +// (the union of all drawn path geometry) rather than mapChart's +// configured width()/height() -- the geo projection doesn't exactly fill +// that nominal canvas (it's inset a bit and slightly overflows the +// right/bottom edges), so using width()/height() directly clipped the +// map's right edge while leaving a blank margin on the left. Call this +// once the map's paths are actually in the DOM. +function setMapContentViewBox(chart, padding) { + var svg = chart.svg(); + if (!svg || !svg.node() || svg.attr("viewBox")) return; + padding = (padding === undefined) ? 4 : padding; + var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + svg.node().querySelectorAll("path").forEach(function(el) { + var b; + try { b = el.getBBox(); } catch(e) { return; } + if (b.width === 0 && b.height === 0) return; + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.width); + maxY = Math.max(maxY, b.y + b.height); }); -}); + if (!isFinite(minX)) return; + svg.attr("viewBox", + (minX - padding) + " " + (minY - padding) + " " + + (maxX - minX + 2 * padding) + " " + (maxY - minY + 2 * padding)) + .attr("preserveAspectRatio", "xMidYMid meet"); +} // Makes the bootstrap tooltip run for votes from before states were contiguous. $(document).ready(function(){$('[data-toggle="tooltip"]').tooltip();}); @@ -583,7 +593,9 @@ function drawWidgets(error, data, geodata, usaboundaries) // We are done defining everything, now let's just run our ancillary functions. dc.renderAll(); d3.select("div#geoMap > span#map-chart > svg").select("g.layer0").select("g").select("path").attr("opacity", 0.3).attr("stroke", "#666666"); + if(!failedMapLoad) setMapContentViewBox(mapChart); decorateNominate(nominateScatterChart, data); + setScatterViewBox(nominateScatterChart); addSponsorCircle(nominateScatterChart); if(!failedMapLoad) mapChart.on("filtered", pollFilters); votePartyChart.on("filtered", pollFilters); @@ -644,5 +656,6 @@ function doFullFilterReset() dc.redrawAll(); // Re-apply our decoration hack. decorateNominate(nominateScatterChart, globalData); + setScatterViewBox(nominateScatterChart); //updateVoteChart(); } From 0ba45db0d43c88290ddbabfc230e68777f640c6d Mon Sep 17 00:00:00 2001 From: Jeffrey Lewis Date: Mon, 7 Sep 2026 21:59:39 +0000 Subject: [PATCH 3/4] Size the scatter chart's container to its real aspect ratio, not a fixed height #scatter-container had a fixed height:425px, sized for the chart at its native 890px width. Once the chart's SVG started scaling its width down to fit narrow phone screens (this branch's earlier commits), its rendered height shrank proportionally too -- but the container's height didn't, leaving a large gap between the bottom of the now-much-shorter chart and whatever follows it on the page (the explanatory text block on the vote page). Fixed by setting the container's aspect-ratio from the chart's actual width()/height() once they're known (setScatterViewBox in decorate.js), so its height always tracks its rendered width. aspect-ratio only takes effect when height isn't otherwise fixed, so height has to be relaxed to auto at the same time -- base.css keeps the old fixed height only as a pre-JS fallback. Verified with headless Chromium: the gap between the chart and the following text is 10px at both 390px and 1280px (was ~260px at 390px before this fix, computed from the container's old fixed 425px height vs. the chart's actual scaled-down rendered height). Re-ran the full 36-check suite from the last two commits on this branch -- all still pass, confirming this doesn't disturb the scatter/map scaling, click interactions, or the sticky/column fixes from the earlier PRs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT --- static/css/base.css | 2 +- static/js/decorate.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/static/css/base.css b/static/css/base.css index 1bd272993..0f4513ac8 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -93,7 +93,7 @@ button.link-button:focus { #scatter-container { width: 100%; max-width: 890px; - height: 425px; + height: 425px; /* Fallback for browsers predating aspect-ratio; see setScatterViewBox in decorate.js, which sets a precise aspect-ratio once the chart's real dimensions are known. */ position: relative; overflow-x: auto; } diff --git a/static/js/decorate.js b/static/js/decorate.js index 12ca8f259..c2441dd11 100644 --- a/static/js/decorate.js +++ b/static/js/decorate.js @@ -13,6 +13,21 @@ function setScatterViewBox(chart) { if (!svg || !svg.node() || svg.attr("viewBox")) return; svg.attr("viewBox", "0 0 " + chart.width() + " " + chart.height()) .attr("preserveAspectRatio", "xMidYMid meet"); + + // #scatter-container has a fixed height (see base.css) as a fallback + // for browsers without aspect-ratio support. Once we know the chart's + // real width/height, size the container to match that ratio instead, + // so a narrow phone doesn't get a chart that's shrunk to fit its width + // while the container underneath stays at the fixed desktop height, + // leaving a large gap before whatever follows the chart on the page. + var container = document.getElementById("scatter-container"); + if (container) { + // aspect-ratio has no effect unless height is allowed to be + // computed from it -- base.css sets a fixed height as a fallback, + // so it has to be relaxed to auto here for the ratio to apply. + container.style.height = "auto"; + container.style.aspectRatio = chart.width() + " / " + chart.height(); + } } /* From ae5f1c09d57cdf82e0ce06fe6faa6c1ad01612e9 Mon Sep 17 00:00:00 2001 From: Jeffrey Lewis Date: Mon, 7 Sep 2026 22:15:01 +0000 Subject: [PATCH 4/4] Add cache-busting to congress.tpl/vote.tpl's own JS, matching the CSS pattern base.tpl already busts its CSS cache on every request (cache_breaker = random int, appended as ?t=... to base.css/dc.css/every extra_css entry), but that variable only exists in base.tpl's own template scope -- Bottle renders a rebase()'d child template like congress.tpl or vote.tpl separately, before splicing its output into base.tpl, so the two don't share template-local variables. The app-level - - - - + + + + diff --git a/views/vote.tpl b/views/vote.tpl index 871459c1a..2cdf86df3 100644 --- a/views/vote.tpl +++ b/views/vote.tpl @@ -1,4 +1,6 @@ % STATIC_URL = "/static/" +% import random +% cache_breaker = random.randint(10000, 99999) % rebase('base.tpl', title=plot_title, extra_css=["map.css","scatter.css", "bootstrap-slider.css"], extra_js=["/static/js/libs/saveSvgAsPng.js", "/static/js/libs/bootstrap-slider.min.js", "/static/js/libs/sticky-kit.min.js", "/static/js/stateMeta.js"]) % include('header.tpl') @@ -166,10 +168,10 @@ var nomBeta = {{ nom_beta }}; - - - - - - - + + + + + + +