Skip to content

Scale the DW-NOMINATE scatter plot and vote/district map to fit their container - #457

Open
JeffreyBLewis wants to merge 4 commits into
fix-vote-list-mobile-columnsfrom
mobile-chart-sizing
Open

Scale the DW-NOMINATE scatter plot and vote/district map to fit their container#457
JeffreyBLewis wants to merge 4 commits into
fix-vote-list-mobile-columnsfrom
mobile-chart-sizing

Conversation

@JeffreyBLewis

Copy link
Copy Markdown
Collaborator

Summary

Stacks on #456 (which stacks on #455) so this diff only shows the new commit, f14099e.

Retry of the approach from #454 (reverted after it broke the site). 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 doesn't change, so D3's mouse/brush math (which resolves screen coordinates through the SVG's own transform) keeps working at any scale.

What broke last time, and the actual fix

The bundled dc.js here predates per-chart renderlet callbacks: dc.renderlet(fn) is a page-global hook, and dc.renderAll()/dc.redrawAll() invoke it as dc._renderlet(group) -- passing the chart-group name (usually undefined), not a chart instance. The previous attempt did chart.svg on that value, throwing a TypeError on every render/redraw call, which silently aborted decorateNominate() (draws the scatter plot's axes/shading) and left every chart without a viewBox at all -- so the CSS just clipped instead of scaling.

Fixed by reading the actual chart list via dc.chartRegistry.list(group), the same lookup dc.renderAll/dc.redrawAll use internally. Applied to all six chart-init files since the hook is global, not per-page: congress.js, voteCharts.js, party.js, committee.js, personIdeology.js, partyGlance.js.

Also carries the container-level CSS from #454 (#scatter-container/#memberList/#memberTextList/.loadVotes/tooltip sizing, #geoMap #map-chart as display:block instead of inline-block to avoid an ambiguous width:100%-in-shrink-to-fit case), wraps #voteList in a scrollable container, and keeps the mapPanZoom.js dimX fix (890 -> 850, to match mapChart.width(850)).

Test plan

This time verified against the actual running app with headless Chromium (Playwright), not just a syntax check and HTTP status codes -- 30 checks across congress/vote/party/person pages at both 390px and 1280px, all passing:

  • No console or page errors on load, and none introduced by interacting with the charts (click-to-brush on the scatter plot, click-a-district on the map)
  • decorateNominate's output (axes, yea/nay cutline labels on a real 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 (Fix vote-count sidebar chart floating over the member list below 992px #455) and single-column vote list (Force single-column vote list on phone widths (fixes 'tiny text' on the vote page) #456) fixes are still intact
  • Manual eyeball on an actual phone/desktop browser, if you get a chance

Found, not fixed (pre-existing, unrelated): 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. Confirmed present on unmodified master before touching any code, so not a regression from this change -- looks like a NaN from a division edge case in the cutline math for lopsided/small subsets. Flagging as a separate, pre-existing bug rather than fixing it here.

Screenshots (390px, before -> after):

🤖 Generated with Claude Code

https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT

JeffreyBLewis and others added 2 commits September 7, 2026 21:09
…flowing

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT
@JeffreyBLewis

Copy link
Copy Markdown
Collaborator Author

Update: both issues reported were real regressions from over-broad scoping in the first commit -- root-caused and fixed in dc765bd.

  1. Map pushed right, Maine clipped. I'd hardcoded the map's viewBox to `0 0 850 500`, assuming it matches `mapChart.width(850).height(500)` exactly. It doesn't -- the actual rendered path geometry spans x:31-866, y:11-497. Content past x=850 was clipped on the right 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 instead of guessing from configured width/height. Also reverted the mapPanZoom.js dimX 890->850 change from the last commit -- same flawed assumption, and since neither number actually matches true content bounds, reverting avoids changing pan/zoom behavior that isn't part of this fix.

  2. Bar chart (#party-chart) text got small on desktop. I'd left the old global dc.renderlet hook registered in voteCharts.js even after adding the new targeted functions, so it was still scaling every chart on the page, including the vote-count sidebar chart. That chart is natively wider (280px) than its column (~195px) -- previously harmless (it just rendered at native size, slightly overflowing unnoticed), but once it got a viewBox the whole thing including text scaled down to fit. Removed the leftover hook and the generic CSS rule entirely; only the scatter plot and map get this treatment now.

Re-verified with headless Chromium: 34 checks (up from 30), all passing, including new checks specifically for these two regressions (map content fully inside its viewBox with clean padding; #party-chart has no viewBox and renders at native width). Screenshots at 1280px and 390px confirm visually -- Maine fully visible, no left margin, bar chart legend text back to its original size.

…xed 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT
@JeffreyBLewis

Copy link
Copy Markdown
Collaborator Author

Another fix added (0ba45db): the large gap between the bottom of the scatter plot oval and the explanatory text block below it on narrow phone widths.

Cause: `#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 screens, its rendered height shrank proportionally too (aspect ratio is fixed), but the container's height stayed at the old fixed 425px, leaving a large empty gap.

Fix: set the container's `aspect-ratio` from the chart's actual width()/height() once known, so its height always tracks its rendered width instead of a stale fixed value.

Verified: gap between chart and following text is 10px at both 390px and 1280px (was ~260px at 390px before this). Full 36-check suite still passes.

… 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 <script> tags each of
those templates writes directly (decorate.js, congress.js, voteCharts.js,
etc.) had no cache-busting at all, unlike the CSS.

This isn't just a nice-to-have: it caused real confusion earlier in this
branch's work -- a JS fix could be live on the server while a browser
that had already loaded the page kept running the old cached copy,
making a real fix look like it hadn't taken effect.

Give each of these two templates its own cache_breaker (same
random.randint(10000, 99999) technique as base.tpl, just computed
locally since the two scopes aren't shared) and append it to every
non-vendor <script src> tag -- vendor libs under js/libs/ are left alone
since they don't change during this work, matching how extra_css already
only busts the app's own stylesheets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013KJMYfgTNsxrmjHZpZKAHT
@JeffreyBLewis

Copy link
Copy Markdown
Collaborator Author

Confirmed stable now -- 45/45 requests clean across congress and vote pages after a direct reload, and the full 36-check verification suite passes. Root cause of the intermittent 500s was stale per-worker template caching (Bottle compiles/caches `.tpl` files in memory per-process; a worker that compiled a momentarily-broken intermediate edit kept erroring until it restarted) combined with `touch-reload` not reaching this specific uwsgi instance.

Also pushed ae5f1c0: added cache-busting to congress.tpl/vote.tpl's own `<script>` tags, matching the pattern base.tpl already uses for CSS. Their JS previously had no cache-busting at all, which is exactly the kind of thing that made this debugging session confusing (a fix live on the server, but a browser still running an old cached copy). Should prevent this specific flavor of "is my fix even live" confusion going forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant