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:
+42
-6
@@ -28,10 +28,13 @@
|
|||||||
.lens:hover{color:var(--ink); border-color:var(--statel)}
|
.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[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}
|
.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}
|
svg{width:100%; height:100%; display:block}
|
||||||
.county{stroke:#0a0f16; stroke-width:.15; transition:opacity .5s}
|
.county{stroke:#0a0f16; stroke-width:.15; vector-effect:non-scaling-stroke; transition:opacity .5s}
|
||||||
.stateline{fill:none; stroke:var(--statel); stroke-width:.5; opacity:.55; pointer-events:none}
|
.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}
|
.county.hot{stroke:var(--ink); stroke-width:.7}
|
||||||
.booting .county{opacity:0}
|
.booting .county{opacity:0}
|
||||||
@media(prefers-reduced-motion:reduce){ .county{transition:none} .booting .county{opacity:1} }
|
@media(prefers-reduced-motion:reduce){ .county{transition:none} .booting .county{opacity:1} }
|
||||||
@@ -109,7 +112,7 @@
|
|||||||
<div class="eyebrow">Republic OS · the board</div>
|
<div class="eyebrow">Republic OS · the board</div>
|
||||||
<h1 id="title">Every county in America, and what it's made of.</h1>
|
<h1 id="title">Every county in America, and what it's made of.</h1>
|
||||||
<div class="lenses" id="lenses"></div>
|
<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>
|
<div class="stagefoot" id="stagefoot"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -198,10 +201,43 @@ function render(){
|
|||||||
html+=`<path class="county" data-f="${f.id}" fill="${colorFor(f.id)}" d="${pathFor(f.geometry)}"></path>`;
|
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>`; });
|
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))); }
|
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 ----
|
// ---- readout ----
|
||||||
const readout=document.getElementById("readout");
|
const readout=document.getElementById("readout");
|
||||||
function show(fips){
|
function show(fips){
|
||||||
@@ -259,7 +295,7 @@ function closeModal(){ modal.hidden=true; }
|
|||||||
document.getElementById("close").onclick=closeModal;
|
document.getElementById("close").onclick=closeModal;
|
||||||
document.getElementById("backdrop").onclick=closeModal;
|
document.getElementById("backdrop").onclick=closeModal;
|
||||||
document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&!modal.hidden)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 ----
|
// ---- legend ----
|
||||||
function renderLegend(){
|
function renderLegend(){
|
||||||
|
|||||||
Reference in New Issue
Block a user