Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Election Map — Click for details, cached by race</title> | |
| <script src="https://d3js.org/d3.v6.min.js"></script> | |
| <script src="https://d3js.org/topojson.v3.min.js"></script> | |
| <style> | |
| /* map HUD */ | |
| #hud{ | |
| position:absolute; left:10px; bottom:10px; z-index:99999; | |
| background:rgba(10,25,45,.85); color:#e9f2ff; | |
| border:1px solid rgba(255,255,255,.18); border-radius:10px; | |
| padding:10px 12px; max-width:320px; font-size:12px; line-height:1.35; | |
| box-shadow:0 6px 24px rgba(0,0,0,.25), inset 0 1px 0 rgba(255,255,255,.06); | |
| backdrop-filter: blur(4px); | |
| } | |
| #hud b{ color:#fff } | |
| #hud .row{ margin:2px 0 } | |
| #hud .dot{ display:inline-block; width:6px; height:6px; border-radius:50%; background:#8fd; | |
| margin-right:6px; vertical-align:middle; } | |
| #hud .muted{ opacity:.8 } | |
| #hud .bar{ height:6px; background:rgba(255,255,255,.15); border-radius:999px; overflow:hidden; margin-top:6px } | |
| #hud .bar > i{ display:block; height:100%; width:0%; background:#7fd; | |
| transition:width .2s ease } | |
| :root{ --gop:#ec1d19; --dem:#0067cb; --panel:#f6f8fc; --line:#d9e1ef; } | |
| html,body{height:100%;margin:0;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#122} | |
| header{padding:.6rem 1rem;border-bottom:3px solid var(--dem);display:flex;align-items:center;justify-content:center} | |
| header b{color:var(--gop)} | |
| #wrap{display:grid;grid-template-columns:280px 1fr 180px;grid-template-rows:1fr;height:calc(100% - 52px)} | |
| aside{background:var(--panel);border-right:1px solid var(--line);overflow:auto} | |
| #right{border-left:1px solid var(--line);display:flex;flex-direction:column;gap:8px;padding:10px} | |
| #right button{padding:8px;border:1px solid var(--line);background:#fff;border-radius:6px;cursor:pointer} | |
| #right button.active{background:var(--dem);color:#fff;border-color:transparent} | |
| #map{position:relative;background:#fff} | |
| svg{width:100%;height:100%} | |
| .cand{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;margin:8px;border-radius:8px;background:#fff;border:1px solid var(--line)} | |
| .cand.leader{box-shadow:0 2px 10px rgba(0,0,0,.06)} | |
| .cand .name{font-weight:700} | |
| .cand.dem .name{color:var(--dem)} | |
| .cand.gop .name{color:var(--gop)} | |
| .muted{opacity:.65} | |
| .row{padding:10px} | |
| .sep{border:none;border-top:1px solid var(--line);margin:8px 10px} | |
| #status{font-size:12px;color:#456} | |
| #details .k{color:#445} | |
| .pill{display:inline-block;padding:.1rem .4rem;border-radius:999px;border:1px solid var(--line);background:#fff;margin-left:.3rem;font-size:.8rem} | |
| /* hover feedback */ | |
| path.feature:hover{filter:brightness(1.05)} | |
| </style> | |
| </head> | |
| <body> | |
| <header><b>Election Map</b> | <span id="h-race">—</span></header> | |
| <div id="wrap"> | |
| <aside id="left"> | |
| <div class="row"><b>Winners</b> <span class="muted" id="scope-label">(select a race)</span></div> | |
| <hr class="sep"> | |
| <div id="winners"><div class="row muted">No data yet.</div></div> | |
| <hr class="sep"> | |
| <div class="row"><b>Details</b></div> | |
| <div id="details" class="row muted">Click a county or district…</div> | |
| <hr class="sep"> | |
| <div class="row" id="status">Idle. Choose a race →</div> | |
| </aside> | |
| <div id="map"> | |
| <svg viewBox="0 0 960 600" preserveAspectRatio="xMidYMid meet"> | |
| <g id="states"></g> | |
| <g id="counties" style="display:none"></g> | |
| <g id="cds" style="display:none"></g> | |
| </svg> | |
| <div id="hud" aria-live="polite"> | |
| <div class="row"><span class="dot"></span><b>Status:</b> Idle</div> | |
| <div class="row"><b>Race:</b> — <span class="muted">(counties/districts)</span></div> | |
| <div class="row"><b>Source:</b> —</div> | |
| <div class="row"><b>Progress:</b> <span id="hud-progress">0 / 0</span></div> | |
| <div class="bar"><i id="hud-bar"></i></div> | |
| <div class="row muted" id="hud-sub">—</div> | |
| </div> | |
| </div> | |
| <aside id="right"> | |
| <button class="race" data-race="P">President</button> | |
| <button class="race" data-race="G">Governor</button> | |
| <button class="race" data-race="S">Senate</button> | |
| <button class="race" data-race="H">House</button> | |
| <hr class="sep"> | |
| <button id="refresh" disabled>Refresh</button> | |
| <button id="autorefresh" disabled>Start Auto</button> | |
| <hr class="sep"> | |
| <div class="row muted" style="font-size:12px"> | |
| Server caches upstream.<br/>Tabs read server snapshot.<br/>Switching races reuses cached data. | |
| </div> | |
| </aside> | |
| </div> | |
| <script> | |
| /* ---------------------------- geometry & layers ---------------------------- */ | |
| const statesG = d3.select("#states"); | |
| const countiesG = d3.select("#counties"); | |
| const cdsG = d3.select("#cds"); | |
| const projection = d3.geoAlbersUsa().translate([480, 300]).scale(1200); | |
| const path = d3.geoPath().projection(projection); | |
| function showCountyLayer(){ countiesG.style("display", null); cdsG.style("display","none"); } | |
| function showDistrictLayer(){ countiesG.style("display","none"); cdsG.style("display", null); } | |
| let statesF=[], countiesF=[], cdF=[]; | |
| let fipsToPostal = {}; | |
| let countyKeyToId = new Map(); // "ST:base" -> county FIPS id | |
| let countyMeta = new Map(); // countyFIPS -> {state, county} | |
| let districtIdSet = new Set(); // valid district GEOIDs | |
| let districtMeta = new Map(); // GEOID -> {state, name} | |
| /* ------------------------------ data storage ------------------------------ */ | |
| /* We keep per-race caches and do NOT clear them when switching tabs. */ | |
| const raceCountyData = { P:new Map(), G:new Map(), S:new Map() }; // countyFIPS -> {candidates,total} | |
| const raceDistrictData = { H:new Map() }; // GEOID -> {candidates,total} | |
| const fetchedStatesByRace = { P:new Set(), G:new Set(), S:new Set(), H:new Set() }; | |
| let currentRace = null; // 'P'|'G'|'S'|'H' | |
| let autoTimer = null; | |
| /* ---------------------------------- UI ------------------------------------ */ | |
| const q = sel => document.querySelector(sel); | |
| const raceBtnEls = [...document.querySelectorAll('.race')]; | |
| const hdrRace = q('#h-race'); | |
| const statusEl = q('#status'); | |
| const winnersEl = q('#winners'); | |
| const scopeLabel = q('#scope-label'); | |
| const refreshBtn = q('#refresh'); | |
| const autoBtn = q('#autorefresh'); | |
| const detailsEl = q('#details'); | |
| raceBtnEls.forEach(btn=>{ | |
| btn.addEventListener('click', ()=>{ | |
| raceBtnEls.forEach(b=>b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| currentRace = btn.dataset.race; | |
| hdrRace.textContent = labelFor(currentRace); | |
| refreshBtn.disabled = false; | |
| autoBtn.disabled = false; | |
| // set visible layer, leave prior data for other races intact | |
| if (currentRace==='H'){ showDistrictLayer(); scopeLabel.textContent='(national – districts)'; } | |
| else { showCountyLayer(); scopeLabel.textContent='(national – counties)'; } | |
| // If we already fetched some data for this race, just repaint from cache. | |
| // Otherwise leave blank until user hits Refresh (or Auto). | |
| paintAll(); | |
| rebuildWinners(); | |
| hud.set('race', `${labelFor(currentRace)} — ${describeLayer()}`); | |
| hud.set('source', 'Server snapshot (backend caches upstream ~15s)'); | |
| hud.set('status', 'Ready to fetch / paint'); | |
| hud.set('progress', { text:'0 / 0', pct:0 }); | |
| hud.set('sub', 'Switch races freely; cached data is reused.'); | |
| setStatus(fetchedStatesByRace[currentRace].size ? 'Showing cached data.' : 'Blank. Click Refresh to fetch.'); | |
| }); | |
| }); | |
| refreshBtn.onclick = refreshAllForCurrentRace; | |
| autoBtn.onclick = ()=>{ | |
| if (autoTimer){ | |
| clearInterval(autoTimer); autoTimer=null; | |
| autoBtn.textContent='Start Auto'; | |
| setStatus('Auto refresh off.'); | |
| hud.set('status','Auto refresh off'); | |
| hud.set('sub','—'); | |
| }else{ | |
| autoTimer = setInterval(refreshAllForCurrentRace, 15000); | |
| autoBtn.textContent='Stop Auto'; | |
| setStatus('Auto refresh on (15s).'); | |
| hud.set('status','Auto refresh on (every 15s)'); | |
| hud.set('sub','Will refresh all states on schedule.'); | |
| } | |
| }; | |
| /* ------------------------------- load maps -------------------------------- */ | |
| Promise.all([ | |
| d3.json("https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json"), | |
| d3.json("https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json"), | |
| d3.json("cb_2024_us_cd119_500k.json") // adjust path if hosted elsewhere | |
| ]).then(([stTopo, ctTopo, cdTopo])=>{ | |
| const stObj = stTopo.objects.states || stTopo.objects[Object.keys(stTopo.objects)[0]]; | |
| const ctObj = ctTopo.objects.counties || ctTopo.objects[Object.keys(ctTopo.objects)[0]]; | |
| const cdObj = cdTopo.objects[Object.keys(cdTopo.objects)[0]]; | |
| statesF = topojson.feature(stTopo, stObj).features; | |
| countiesF = topojson.feature(ctTopo, ctObj).features; | |
| cdF = topojson.feature(cdTopo, cdObj).features; | |
| // state FIPS -> USPS | |
| const nameToPostal = { | |
| "Alabama":"AL","Alaska":"AK","Arizona":"AZ","Arkansas":"AR","California":"CA","Colorado":"CO", | |
| "Connecticut":"CT","Delaware":"DE","Florida":"FL","Georgia":"GA","Hawaii":"HI","Idaho":"ID", | |
| "Illinois":"IL","Indiana":"IN","Iowa":"IA","Kansas":"KS","Kentucky":"KY","Louisiana":"LA", | |
| "Maine":"ME","Maryland":"MD","Massachusetts":"MA","Michigan":"MI","Minnesota":"MN", | |
| "Mississippi":"MS","Missouri":"MO","Montana":"MT","Nebraska":"NE","Nevada":"NV", | |
| "New Hampshire":"NH","New Jersey":"NJ","New Mexico":"NM","New York":"NY", | |
| "North Carolina":"NC","North Dakota":"ND","Ohio":"OH","Oklahoma":"OK","Oregon":"OR", | |
| "Pennsylvania":"PA","Rhode Island":"RI","South Carolina":"SC","South Dakota":"SD", | |
| "Tennessee":"TN","Texas":"TX","Utah":"UT","Vermont":"VT","Virginia":"VA","Washington":"WA", | |
| "West Virginia":"WV","Wisconsin":"WI","Wyoming":"WY","District of Columbia":"DC" | |
| }; | |
| statesF.forEach(s=>{ | |
| const f2 = String(s.id).padStart(2,"0"); | |
| fipsToPostal[f2] = nameToPostal[s.properties.name]; | |
| }); | |
| // county base-name index + meta | |
| const SUFFIX_RE = /\s+(city and borough|census area|borough|municipality|parish|county)$/i; | |
| function baseName(n){ | |
| let t = (n||"").toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,''); | |
| t = t.replace(/[^\w\s]/g,'').trim(); | |
| return t.replace(SUFFIX_RE,''); | |
| } | |
| countiesF.forEach(c=>{ | |
| const fid = String(c.id).padStart(5,'0'); | |
| const st = fipsToPostal[fid.slice(0,2)]; | |
| const display = c.properties.name; | |
| countyKeyToId.set(st + ":" + baseName(display), c.id); | |
| countyMeta.set(fid, {state:st, county:display}); | |
| }); | |
| // district meta | |
| districtIdSet = new Set(cdF.map(f => (f.properties.GEOID||"").trim())); | |
| cdF.forEach(f=>{ | |
| const geoid = String(f.properties.GEOID||"").trim(); | |
| const st = fipsToPostal[String(f.properties.STATEFP||"").padStart(2,"0")]; | |
| const name = f.properties.NAMELSAD || `District ${geoid.slice(2)}`; | |
| districtMeta.set(geoid, {state:st, name}); | |
| }); | |
| const districtNumToGeoid = new Map(); | |
| cdF.forEach(f=>{ | |
| const stFips = String(f.properties.STATEFP || "").padStart(2,"0"); | |
| const st = fipsToPostal[stFips]; | |
| const code2 = String( | |
| f.properties.CD119FP || f.properties.CD118FP || f.properties.CDFP || f.properties.CD | |
| ).padStart(2,"0"); | |
| const geoid = String(f.properties.GEOID || "").trim(); | |
| if (st && code2 && geoid) districtNumToGeoid.set(`${st}-${code2}`, geoid); | |
| }); | |
| // draw base layers (neutral) + click handlers | |
| statesG.selectAll("path").data(statesF).join("path") | |
| .attr("d", path).attr("fill","#f7f9ff") | |
| .attr("stroke","#b9c6d7").attr("stroke-width",1.1) | |
| .attr("vector-effect","non-scaling-stroke"); | |
| countiesG.selectAll("path").data(countiesF).join("path") | |
| .attr("class","feature") | |
| .attr("d", path).attr("fill","#eee") | |
| .attr("stroke","#fff").attr("stroke-width",.5) | |
| .attr("vector-effect","non-scaling-stroke") | |
| .on("click", (ev,d)=>handleClickCounty(d)); | |
| cdsG.selectAll("path").data(cdF).join("path") | |
| .attr("class","feature") | |
| .attr("d", path).attr("fill","#eee") | |
| .attr("stroke","#fff").attr("stroke-width",.6) | |
| .attr("vector-effect","non-scaling-stroke") | |
| .on("click", (ev,d)=>handleClickDistrict(d)); | |
| setStatus('Maps loaded. Pick a race to fetch data.'); | |
| }); | |
| /* -------------------------- fetch (server snapshot) ------------------------ */ | |
| async function refreshAllForCurrentRace(){ | |
| if (!currentRace){ | |
| setStatus('Choose a race first.'); | |
| return; | |
| } | |
| hud.set('status', 'Refreshing…'); | |
| hud.set('source', 'Server snapshot (backend caches upstream ~15s)'); | |
| hud.set('race', `${labelFor(currentRace)} — ${describeLayer()}`); | |
| hud.set('sub', 'Fetching all races, one state at a time; repainting after each.'); | |
| const states = Object.values(fipsToPostal); | |
| let done = 0; | |
| hud.set('progress', { text: `0 / ${states.length}`, pct: 0 }); | |
| // Always fetch all four races | |
| for (const st of states){ | |
| await fetchStateBulk(st, 'P'); | |
| await fetchStateBulk(st, 'G'); | |
| await fetchStateBulk(st, 'S'); | |
| await fetchDistrictBulk(st); | |
| done++; | |
| hud.set('progress', { | |
| text: `${done} / ${states.length}`, | |
| pct: Math.round(done/states.length*100) | |
| }); | |
| hud.set('sub', `Last: ${st} (${new Date().toLocaleTimeString()})`); | |
| } | |
| hud.set('status', 'Up to date'); | |
| hud.set('sub', 'Last paint: ' + new Date().toLocaleTimeString()); | |
| setStatus('Last update: ' + new Date().toLocaleTimeString()); | |
| paintAll(); | |
| rebuildWinners(); | |
| } | |
| async function fetchStateBulk(stateCode, race){ | |
| const url = `/results_state?state=${stateCode}&office=${race}`; | |
| try{ | |
| const r = await fetch(url, {cache:'no-store'}); | |
| if (!r.ok) return false; | |
| const data = await r.json(); | |
| if (!data.ok || !data.counties) return false; | |
| const store = raceCountyData[race]; | |
| for (const [base, info] of Object.entries(data.counties)){ | |
| const id = countyKeyToId.get(stateCode + ":" + base); | |
| if (!id) continue; | |
| const keyId = String(id).padStart(5,'0'); | |
| const cand = {}; | |
| (info.candidates||[]).forEach(c=>{ | |
| const nm = (race==='P') ? (c.name||'').trim().split(' ').pop() : (c.name||'').trim(); | |
| cand[nm] = { votes:+c.votes||0, party:(c.party||'').toUpperCase() }; | |
| }); | |
| const total = Object.values(cand).reduce((s,c)=>s+(c.votes||0),0); | |
| store.set(keyId, { candidates:cand, total }); | |
| } | |
| fetchedStatesByRace[race].add(stateCode); | |
| return true; | |
| }catch(_){ return false; } | |
| } | |
| // index.html — replace fetchDistrictBulk with this | |
| async function fetchDistrictBulk(stateCode){ | |
| const tryUrls = [ | |
| `/results_districts?state=${stateCode}&office=H`, | |
| // (optional fallback) `/results_cd?state=${stateCode}&district=1` | |
| ]; | |
| let data = null; | |
| for (const url of tryUrls){ | |
| try{ | |
| const r = await fetch(url, { cache:'no-store' }); | |
| if (!r.ok) continue; | |
| const j = await r.json(); | |
| // only accept payloads that are ok and contain bulk districts | |
| if (j && j.ok && (j.districts || j.cd)) { data = j; break; } | |
| }catch(_){} | |
| } | |
| if (!data) return false; | |
| const store = raceDistrictData.H; | |
| const src = data.districts || data.cd || {}; | |
| let accepted = 0; | |
| for (const [rawKey, info] of Object.entries(src)) { | |
| let geoid = String(rawKey).trim(); | |
| if (!districtIdSet.has(geoid)) { | |
| // try to coerce: "1", "01", "AL-01", "Congressional District 1" | |
| const digits = (geoid.match(/\d+/) || [""])[0].padStart(2,"0"); | |
| const guess = districtNumToGeoid.get(`${stateCode}-${digits}`); | |
| if (guess) geoid = guess; | |
| } | |
| if (!districtIdSet.has(geoid)) continue; | |
| const cand = {}; | |
| (info.candidates || []).forEach(c=>{ | |
| const nm = (c.name||'').trim(); | |
| cand[nm] = { votes:+c.votes||0, party:(c.party||'').toUpperCase() }; | |
| }); | |
| const total = Object.values(cand).reduce((s,c)=>s+(c.votes||0),0); | |
| store.set(geoid, { candidates:cand, total }); | |
| accepted++; | |
| } | |
| fetchedStatesByRace.H.add(stateCode); | |
| return accepted > 0; | |
| } | |
| /* --------------------------------- paint ---------------------------------- */ | |
| function mix(a,b,t){ return Math.round(a+(b-a)*t); } | |
| function hex(r,g,b){ return "#"+[r,g,b].map(x=>x.toString(16).padStart(2,"0")).join(""); } | |
| function colourCellDemRep(v){ | |
| if (!v) return "#eee"; | |
| let dem=0, gop=0; | |
| for (const k in v.candidates){ | |
| const c=v.candidates[k]; | |
| if (c.party==="DEM") dem += c.votes||0; | |
| else if (c.party==="REP") gop += c.votes||0; | |
| } | |
| if (dem===0 && gop===0) return "#eee"; | |
| const pct = dem/(dem+gop); | |
| const blue=[0,103,203], red=[236,29,25]; | |
| const t = Math.abs(pct-0.5)*2; | |
| const rgb = pct>=.5 | |
| ? [mix(red[0],blue[0],t),mix(red[1],blue[1],t),mix(red[2],blue[2],t)] | |
| : [mix(blue[0],red[0],t),mix(blue[1],red[1],t),mix(blue[2],red[2],t)]; | |
| return hex(...rgb); | |
| } | |
| function paintAll(){ | |
| // counties for P/G/S | |
| countiesG.selectAll("path") | |
| .attr("fill", d => { | |
| const id = String(d.id).padStart(5,'0'); | |
| const store = currentRace && currentRace!=='H' ? raceCountyData[currentRace] : null; | |
| const cell = store ? store.get(id) : null; | |
| return colourCellDemRep(cell); | |
| }); | |
| // districts for H | |
| cdsG.selectAll("path") | |
| .attr("fill", d => { | |
| const geoid = String(d.properties.GEOID||"").trim(); | |
| const cell = raceDistrictData.H.get(geoid); | |
| return colourCellDemRep(cell); | |
| }); | |
| // state tint by visible layer aggregate | |
| const agg = new Map(); | |
| if (currentRace==='H'){ | |
| raceDistrictData.H.forEach((v, geoid)=>{ | |
| const st = String(geoid).slice(0,2); | |
| let a = agg.get(st) || {d:0,r:0}; | |
| for (const k in v.candidates){ | |
| const c=v.candidates[k]; | |
| if (c.party==="DEM") a.d += c.votes||0; | |
| else if (c.party==="REP") a.r += c.votes||0; | |
| } | |
| agg.set(st, a); | |
| }); | |
| } else if (currentRace){ | |
| raceCountyData[currentRace].forEach((v, id)=>{ | |
| const st = String(id).slice(0,2); | |
| let a = agg.get(st) || {d:0,r:0}; | |
| for (const k in v.candidates){ | |
| const c=v.candidates[k]; | |
| if (c.party==="DEM") a.d += c.votes||0; | |
| else if (c.party==="REP") a.r += c.votes||0; | |
| } | |
| agg.set(st, a); | |
| }); | |
| } | |
| statesG.selectAll("path").attr("fill", s=>{ | |
| const a = agg.get(String(s.id).padStart(2,'0')); | |
| if (!a) return "#f7f9ff"; | |
| const v = {candidates:{D:{party:"DEM",votes:a.d},R:{party:"REP",votes:a.r}}}; | |
| return colourCellDemRep(v); | |
| }); | |
| } | |
| /* ------------------------------ winners panel ------------------------------ */ | |
| function rebuildWinners(){ | |
| winnersEl.innerHTML = ""; | |
| if (!currentRace){ | |
| winnersEl.innerHTML = '<div class="row muted">No data yet.</div>'; return; | |
| } | |
| let D=0, R=0, O=0, total=0; | |
| if (currentRace==='H'){ | |
| raceDistrictData.H.forEach(v=>{ | |
| for (const k in v.candidates){ | |
| const c=v.candidates[k]; | |
| if (c.party==="DEM") D += c.votes||0; | |
| else if (c.party==="REP") R += c.votes||0; | |
| else O += c.votes||0; | |
| } | |
| }); | |
| } else { | |
| raceCountyData[currentRace].forEach(v=>{ | |
| for (const k in v.candidates){ | |
| const c=v.candidates[k]; | |
| if (c.party==="DEM") D += c.votes||0; | |
| else if (c.party==="REP") R += c.votes||0; | |
| else O += c.votes||0; | |
| } | |
| }); | |
| } | |
| total = D+R+O; | |
| if (!total){ winnersEl.innerHTML = '<div class="row muted">No data yet.</div>'; return; } | |
| const rows = [ | |
| {name:"Democrat", votes:D, cls:"dem"}, | |
| {name:"Republican", votes:R, cls:"gop"}, | |
| ]; | |
| if (O>0) rows.push({name:"Other", votes:O, cls:""}); | |
| rows.sort((a,b)=>b.votes-a.votes); | |
| rows.forEach((r,i)=>{ | |
| const el = document.createElement('div'); | |
| el.className = 'cand ' + r.cls + (i===0 ? ' leader' : ''); | |
| el.innerHTML = ` | |
| <span class="name">${r.name}</span> | |
| <span class="votes">${r.votes.toLocaleString()} (${(r.votes/total*100).toFixed(1)}%)</span>`; | |
| winnersEl.appendChild(el); | |
| }); | |
| } | |
| /* ------------------------------ click details ------------------------------ */ | |
| function handleClickCounty(feature){ | |
| if (!currentRace || currentRace==='H'){ detailsEl.innerHTML = '<span class="muted">Switch to P/G/S to see county results.</span>'; return; } | |
| const id = String(feature.id).padStart(5,'0'); | |
| const meta = countyMeta.get(id) || {state:'', county:'(unknown)'}; | |
| const cell = raceCountyData[currentRace].get(id); | |
| if (!cell){ | |
| detailsEl.innerHTML = `<div><span class="k">${meta.county}, ${meta.state}</span> <span class="pill">${labelFor(currentRace)}</span><div class="muted">No data in cache yet.</div></div>`; | |
| return; | |
| } | |
| showCellDetails(`${meta.county}, ${meta.state}`, cell, labelFor(currentRace)); | |
| } | |
| function handleClickDistrict(feature){ | |
| if (currentRace!=='H'){ detailsEl.innerHTML = '<span class="muted">Switch to House to see district results.</span>'; return; } | |
| const geoid = String(feature.properties.GEOID||"").trim(); | |
| const meta = districtMeta.get(geoid) || {state:'', name:`District ${geoid.slice(2)}`}; | |
| const cell = raceDistrictData.H.get(geoid); | |
| if (!cell){ | |
| detailsEl.innerHTML = `<div><span class="k">${meta.name}, ${meta.state}</span> <span class="pill">House</span><div class="muted">No data in cache yet.</div></div>`; | |
| return; | |
| } | |
| showCellDetails(`${meta.name}, ${meta.state}`, cell, 'House'); | |
| } | |
| function showCellDetails(title, cell, raceLabel){ | |
| const rows = Object.entries(cell.candidates||{}) | |
| .map(([name,obj])=>({name,party:obj.party||'',votes:+obj.votes||0})) | |
| .sort((a,b)=>b.votes-a.votes); | |
| const total = rows.reduce((s,r)=>s+r.votes,0) || 1; | |
| let html = `<div><span class="k">${title}</span> <span class="pill">${raceLabel}</span></div>`; | |
| html += `<div class="muted" style="margin:.2rem 0 .4rem">Total votes: ${total.toLocaleString()}</div>`; | |
| rows.forEach(r=>{ | |
| const tag = r.party==='DEM'?'(D)':r.party==='REP'?'(R)':r.party?`(${r.party})`:''; | |
| html += `<div class="cand ${r.party==='DEM'?'dem':r.party==='REP'?'gop':''}"> | |
| <span class="name">${r.name} ${tag}</span> | |
| <span>${r.votes.toLocaleString()} (${(r.votes/total*100).toFixed(1)}%)</span> | |
| </div>`; | |
| }); | |
| detailsEl.innerHTML = html; | |
| } | |
| /* --------------------------------- helpers -------------------------------- */ | |
| function labelFor(r){ return r==='P'?'President':r==='G'?'Governor':r==='S'?'Senate':'House'; } | |
| function setStatus(msg){ statusEl.textContent = msg; } | |
| /* ------------------------------- HUD helpers ------------------------------- */ | |
| const hud = { | |
| el: document.getElementById('hud'), | |
| progEl: document.getElementById('hud-progress'), | |
| barEl: document.getElementById('hud-bar'), | |
| subEl: document.getElementById('hud-sub'), | |
| set(k, v){ | |
| const map = { | |
| status: 0, race: 1, source: 2, progress: 3, sub: 5 | |
| }; | |
| const rows = this.el.querySelectorAll('.row'); | |
| if (k === 'status') rows[0].innerHTML = `<span class="dot"></span><b>Status:</b> ${v}`; | |
| if (k === 'race') rows[1].innerHTML = `<b>Race:</b> ${v}`; | |
| if (k === 'source') rows[2].innerHTML = `<b>Source:</b> ${v}`; | |
| if (k === 'progress'){ | |
| this.progEl.textContent = v.text; | |
| this.barEl.style.width = v.pct + '%'; | |
| } | |
| if (k === 'sub') this.subEl.textContent = v; | |
| } | |
| }; | |
| function describeLayer(){ | |
| return (currentRace === 'H') ? 'districts (House)' : 'counties (' + labelFor(currentRace) + ')'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |