Board: make the county map zoomable and pannable

Wrap the county paths in a <g id="mapg"> 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 <noreply@anthropic.com>
This commit is contained in:
Fabio
2026-07-06 08:25:12 -04:00
parent 886938f1a9
commit 77e7b4282e
+42 -6
View File
@@ -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 @@
<div class="eyebrow">Republic OS · the board</div>
<h1 id="title">Every county in America, and what it's made of.</h1>
<div class="lenses" id="lenses"></div>
<div class="mapwrap"><svg id="map" preserveAspectRatio="xMidYMid meet"></svg></div>
<div class="mapwrap"><svg id="map" preserveAspectRatio="xMidYMid meet"></svg><div class="maphint">scroll to zoom · drag to pan · double-click to reset</div></div>
<div class="stagefoot" id="stagefoot"></div>
</section>
@@ -198,10 +201,43 @@ function render(){
html+=`<path class="county" data-f="${f.id}" fill="${colorFor(f.id)}" d="${pathFor(f.geometry)}"></path>`;
});
STATES.features.forEach(f=>{ html+=`<path class="stateline" d="${pathFor(f.geometry)}"></path>`; });
map.innerHTML=html;
map.innerHTML='<g id="mapg">'+html+'</g>'; mapg=document.getElementById("mapg"); applyTransform();
}
function recolor(){ map.querySelectorAll(".county").forEach(p=>p.setAttribute("fill",colorFor(p.dataset.f))); }
// ---- zoom / pan (transform on <g id="mapg">; 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(){