From 77e7b4282e2344fc3180ffd72b987e8e39bc6cf7 Mon Sep 17 00:00:00 2001 From: Fabio Date: Mon, 6 Jul 2026 08:25:12 -0400 Subject: [PATCH] Board: make the county map zoomable and pannable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the county paths in a and drive a translate/scale transform from the mouse: - scroll to zoom toward the cursor (1x-16x) - drag to pan, clamped so the map can't leave the frame - double-click to reset to the full view County borders use vector-effect:non-scaling-stroke so they stay crisp at every zoom level instead of thickening. The existing hover readout and click-to-open county sheet keep working through event delegation; the click handler is guarded (dragMoved / e.detail) so a pan gesture or the reset double-click never pops a county sheet. Pure view interaction — no data touched. Safe against `make board`, which only rewrites the D/CDETAIL/TOTALS data lines. Co-Authored-By: Claude Opus 4.8 --- viz/board.html | 48 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/viz/board.html b/viz/board.html index f1d869c8f7..816369b63c 100644 --- a/viz/board.html +++ b/viz/board.html @@ -28,10 +28,13 @@ .lens:hover{color:var(--ink); border-color:var(--statel)} .lens[aria-pressed="true"]{color:var(--ground); background:var(--teal); border-color:var(--teal); font-weight:600} .lens:focus-visible{outline:2px solid var(--teal); outline-offset:2px} - .mapwrap{position:absolute; inset:132px 8px 34px} + .mapwrap{position:absolute; inset:132px 8px 34px; cursor:grab; touch-action:none} + .mapwrap.grab{cursor:grabbing} + .maphint{position:absolute; left:10px; bottom:8px; font-size:10px; letter-spacing:.04em; + color:#5c6b7a; opacity:.6; pointer-events:none; user-select:none} svg{width:100%; height:100%; display:block} - .county{stroke:#0a0f16; stroke-width:.15; transition:opacity .5s} - .stateline{fill:none; stroke:var(--statel); stroke-width:.5; opacity:.55; pointer-events:none} + .county{stroke:#0a0f16; stroke-width:.15; vector-effect:non-scaling-stroke; transition:opacity .5s} + .stateline{fill:none; stroke:var(--statel); stroke-width:.5; vector-effect:non-scaling-stroke; opacity:.55; pointer-events:none} .county.hot{stroke:var(--ink); stroke-width:.7} .booting .county{opacity:0} @media(prefers-reduced-motion:reduce){ .county{transition:none} .booting .county{opacity:1} } @@ -109,7 +112,7 @@
Republic OS · the board

Every county in America, and what it's made of.

-
+
scroll to zoom · drag to pan · double-click to reset
@@ -198,10 +201,43 @@ function render(){ html+=``; }); STATES.features.forEach(f=>{ html+=``; }); - map.innerHTML=html; + map.innerHTML=''+html+''; mapg=document.getElementById("mapg"); applyTransform(); } function recolor(){ map.querySelectorAll(".county").forEach(p=>p.setAttribute("fill",colorFor(p.dataset.f))); } +// ---- zoom / pan (transform on ; strokes are non-scaling) ---- +let mapg=null, zk=1, ztx=0, zty=0; +const ZMIN=1, ZMAX=16; +function clampPan(){ ztx=Math.min(0,Math.max((1-zk)*W, ztx)); zty=Math.min(0,Math.max((1-zk)*H, zty)); } +function applyTransform(){ if(!mapg)return; clampPan(); + mapg.setAttribute("transform",`translate(${ztx.toFixed(2)} ${zty.toFixed(2)}) scale(${zk.toFixed(4)})`); } +function vbPoint(e){ const pt=map.createSVGPoint(); pt.x=e.clientX; pt.y=e.clientY; + const m=map.getScreenCTM(); return m?pt.matrixTransform(m.inverse()):{x:0,y:0}; } +map.addEventListener("wheel",e=>{ + e.preventDefault(); + const v=vbPoint(e), old=zk; + zk=Math.min(ZMAX,Math.max(ZMIN, zk*(e.deltaY<0?1.18:1/1.18))); + if(zk===old)return; + ztx=v.x-(v.x-ztx)*(zk/old); zty=v.y-(v.y-zty)*(zk/old); + applyTransform(); +},{passive:false}); +const wrap=document.querySelector(".mapwrap"); +let dragging=false, dragLast=null, dragStart=null, dragMoved=false; +map.addEventListener("pointerdown",e=>{ + dragging=true; dragMoved=false; dragStart=dragLast=vbPoint(e); + wrap.classList.add("grab"); try{map.setPointerCapture(e.pointerId);}catch(_){} +}); +map.addEventListener("pointermove",e=>{ + if(!dragging)return; + const v=vbPoint(e); ztx+=v.x-dragLast.x; zty+=v.y-dragLast.y; dragLast=v; + if(Math.hypot(v.x-dragStart.x, v.y-dragStart.y)>3) dragMoved=true; + applyTransform(); +}); +function endDrag(e){ dragging=false; wrap.classList.remove("grab"); try{map.releasePointerCapture(e.pointerId);}catch(_){} } +map.addEventListener("pointerup",endDrag); +map.addEventListener("pointercancel",endDrag); +map.addEventListener("dblclick",e=>{ e.preventDefault(); zk=1; ztx=0; zty=0; applyTransform(); }); + // ---- readout ---- const readout=document.getElementById("readout"); function show(fips){ @@ -259,7 +295,7 @@ function closeModal(){ modal.hidden=true; } document.getElementById("close").onclick=closeModal; document.getElementById("backdrop").onclick=closeModal; document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&!modal.hidden)closeModal(); }); -map.addEventListener("click",e=>{ const t=e.target; if(t.dataset&&t.dataset.f)openDetail(t.dataset.f); }); +map.addEventListener("click",e=>{ const t=e.target; if(dragMoved||e.detail>1)return; if(t.dataset&&t.dataset.f)openDetail(t.dataset.f); }); // ---- legend ---- function renderLegend(){