← back to Firecities
public/index.html
450 lines
<!doctype html>
<html lang="en">
<head>
<script src="/drill.js"></script>
<script>(function(){try{var t=localStorage.getItem('firecities-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>California Fire Communities · DINS Structures</title>
<style>
:root{
--bg:#160f0d; --panel:#221513; --panel2:#2c1a16; --edge:#43261f;
--ink:#f3e9e6; --mut:#c39a8e; --accent:#ff7a45; --accent2:#c2410c;
--ember:#ff5722; --green:#4caf50; --yellow:#ffc107; --red:#f44336;
--cols:4;
}
html[data-theme="light"]{
--bg:#fff4ef; --panel:#ffffff; --panel2:#fff0e9; --edge:#f0d3c6;
--ink:#2a1712; --mut:#8a5c48;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
.wrap{max-width:1400px;margin:0 auto;padding:32px 20px 80px}
/* Header */
header.top{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:4px}
header.top h1{font-size:24px;font-weight:700;margin:0;letter-spacing:.2px;display:flex;align-items:center;gap:10px}
header.top h1 .fl{color:var(--ember);font-size:22px}
.sub{color:var(--mut);margin:0 0 6px;font-size:13px}
.stat-bar{display:flex;gap:16px;flex-wrap:wrap;margin:12px 0 20px;padding:14px 18px;
background:var(--panel);border:1px solid var(--edge);border-radius:12px}
.stat{text-align:center}
.stat .n{font-size:22px;font-weight:700;color:var(--ember)}
.stat .l{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);margin-top:2px}
/* Controls */
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:18px;
padding:14px 16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px}
.controls label{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);white-space:nowrap}
input[type=text],select{background:var(--panel2);color:var(--ink);border:1px solid var(--edge);
border-radius:8px;padding:8px 12px;font:inherit;min-width:0}
input[type=text]:focus,select:focus{outline:2px solid var(--accent);outline-offset:1px}
input[type=text].search{flex:1 1 200px;min-width:140px}
select.sort-sel,select.dmg-sel,select.county-sel{flex:0 0 auto}
/* Density slider */
.density-wrap{display:flex;align-items:center;gap:8px;white-space:nowrap}
.density-wrap label{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
input[type=range]{accent-color:var(--accent);cursor:pointer;width:80px}
/* Theme toggle */
.theme-btn{background:none;border:1px solid var(--edge);color:var(--mut);border-radius:8px;
padding:6px 12px;cursor:pointer;font-size:13px}
.theme-btn:hover{border-color:var(--accent);color:var(--ink)}
/* Count line */
.count-line{color:var(--mut);font-size:13px;margin-bottom:12px;display:flex;align-items:center;gap:10px}
.count-line strong{color:var(--ink)}
.loading{color:var(--mut);font-size:13px;padding:40px 0;text-align:center}
.error-msg{color:#f44336;padding:20px;background:var(--panel);border:1px solid var(--edge);border-radius:10px;margin:20px 0}
/* Grid */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:10px}
@media(max-width:900px){.grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:540px){.grid{grid-template-columns:1fr}}
/* Cards */
.card{background:var(--panel2);border:1px solid var(--edge);border-radius:10px;
padding:14px;cursor:pointer;transition:.12s;position:relative}
.card:hover{border-color:var(--accent);background:var(--panel);transform:translateY(-1px)}
.card .city-name{font-weight:700;font-size:15px;margin-bottom:3px;line-height:1.2}
.card .county{font-size:12px;color:var(--mut);margin-bottom:10px}
.card .dmg-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.badge{font-size:11px;border-radius:20px;padding:3px 9px;border:1px solid;font-weight:600;white-space:nowrap}
.badge.destroyed{color:#ff6b47;border-color:#ff6b4740;background:#ff6b4712}
.badge.major{color:#ffc107;border-color:#ffc10740;background:#ffc10712}
.badge.minor{color:#64b5f6;border-color:#64b5f640;background:#64b5f612}
.badge.affected{color:#81c784;border-color:#81c78440;background:#81c78412}
.badge.nodamage{color:var(--mut);border-color:var(--edge)}
.card .incidents{font-size:11px;color:var(--mut);line-height:1.3;overflow:hidden;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.card .last-fire{font-size:11px;color:var(--mut);margin-top:6px}
.card .total-badge{position:absolute;top:10px;right:10px;font-size:11px;color:var(--mut);
background:var(--bg);border:1px solid var(--edge);border-radius:20px;padding:2px 8px}
/* Pagination */
.pager{display:flex;gap:8px;align-items:center;justify-content:center;margin-top:24px;flex-wrap:wrap}
.pager button{background:var(--panel);border:1px solid var(--edge);color:var(--ink);
border-radius:8px;padding:7px 16px;cursor:pointer;font:inherit}
.pager button:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
.pager button:disabled{opacity:.4;cursor:default}
.pager .pg-info{color:var(--mut);font-size:13px}
/* Data status banner */
.status-banner{background:var(--panel);border:1px solid var(--edge);border-radius:12px;
padding:20px 24px;margin-bottom:24px;text-align:center}
.status-banner p{margin:6px 0;color:var(--mut)}
.status-banner code{background:var(--bg);border:1px solid var(--edge);border-radius:6px;
padding:4px 10px;font-size:13px;color:var(--accent)}
/* Modal */
.modal-overlay{display:none;position:fixed;inset:0;background:#0008;z-index:100;align-items:center;justify-content:center}
.modal-overlay.open{display:flex}
.modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;
max-width:700px;width:90%;max-height:80vh;overflow:hidden;display:flex;flex-direction:column}
.modal-head{padding:18px 20px;border-bottom:1px solid var(--edge);display:flex;align-items:center;justify-content:space-between}
.modal-head h2{margin:0;font-size:18px}
.modal-close{background:none;border:none;color:var(--mut);cursor:pointer;font-size:20px;padding:0 6px}
.modal-body{overflow-y:auto;padding:18px 20px;flex:1}
.struct-list{display:grid;gap:6px}
.struct-row{background:var(--panel2);border:1px solid var(--edge);border-radius:8px;padding:9px 12px;font-size:13px}
.struct-row .addr{font-weight:600}
.struct-row .meta{color:var(--mut);font-size:12px;margin-top:2px}
</style>
</head>
<body>
<input type="hidden" id="q" value=""><input type="hidden" id="pattern" value="">
<script>
// href-drill globals (TK-10903) — must be var so drill.js's free-variable refs resolve at call time
var FIELDS=[['county','County'],['damage','Damage'],['city','City']];
var FIL={county:'',damage:'',city:''};
var $=function(s){return document.querySelector(s);};
function esc(s){return String(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
// applyFilters — placeholder; overridden inside the IIFE once loadPage is in scope
var applyFilters=function(push){if(push&&typeof writeURL==='function')writeURL(true);if(typeof window.__fcLoadPage==='function')window.__fcLoadPage(0);};
</script>
<div class="wrap">
<header class="top">
<h1><span class="fl">🔥</span> California Fire Communities</h1>
<button class="theme-btn" id="themeBtn">☀ Light</button>
</header>
<p class="sub">CAL FIRE Damage Inspection (DINS) dataset · All wildfire-affected structures in California since 2013</p>
<div class="stat-bar" id="statBar">
<div class="stat"><div class="n" id="sTotal">—</div><div class="l">Total Structures</div></div>
<div class="stat"><div class="n" id="sDestroyed">—</div><div class="l">Destroyed (>50%)</div></div>
<div class="stat"><div class="n" id="sCities">—</div><div class="l">Communities</div></div>
<div class="stat"><div class="n" id="sSource">—</div><div class="l">Data Source</div></div>
</div>
<div id="noBanner" class="status-banner" style="display:none">
<p>⚠ DINS data not yet fetched. Run the fetch script to load structure data:</p>
<p><code>node scripts/fetch-dins.js --limit=5000</code></p>
<p style="font-size:12px">For the full ~132,000-record dataset (takes ~2 min): <code>node scripts/fetch-dins.js</code></p>
</div>
<div class="controls">
<input type="text" class="search" id="search" placeholder="Search city or community…" autocomplete="off">
<div>
<label>Sort</label><br>
<select class="sort-sel" id="sortSel">
<option value="destroyed">Most Destroyed</option>
<option value="total">Most Structures</option>
<option value="name">City A→Z</option>
<option value="county">County A→Z</option>
<option value="date">Most Recent Fire</option>
</select>
</div>
<div>
<label>Damage Filter</label><br>
<select class="dmg-sel" id="dmgSel">
<option value="">All Damage Levels</option>
<option value="Destroyed (>50%)">Destroyed (>50%)</option>
<option value="Major (25-50%)">Major (25–50%)</option>
<option value="Minor (10-25%)">Minor (10–25%)</option>
<option value="Affected (>0-10%)">Affected (>0–10%)</option>
</select>
</div>
<div>
<label>County</label><br>
<select class="county-sel" id="countySel">
<option value="">All Counties</option>
</select>
</div>
<div class="density-wrap">
<label>Density</label><br>
<input type="range" id="densitySlider" min="1" max="6" value="4" step="1">
</div>
</div>
<div class="count-line" id="countLine"></div>
<div id="gridEl" class="grid"></div>
<div id="loadingEl" class="loading" style="display:none">Loading…</div>
<div class="pager" id="pager" style="display:none">
<button id="prevBtn" disabled>← Prev</button>
<span class="pg-info" id="pgInfo"></span>
<button id="nextBtn">Next →</button>
</div>
</div>
<!-- Detail modal -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-head">
<h2 id="modalTitle">City</h2>
<button class="modal-close" id="modalClose">✕</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
(function() {
// ── State ──────────────────────────────────────────────────────────────────
var PAGE_SIZE = 100;
var offset = 0;
var totalCount = 0;
var dataOk = false;
var debounceT = null;
var openCity = null;
// ── Restore localStorage ───────────────────────────────────────────────────
try {
var savedCols = localStorage.getItem('firecities-cols');
if (savedCols) document.documentElement.style.setProperty('--cols', savedCols);
var savedSort = localStorage.getItem('firecities-sort');
if (savedSort) document.getElementById('sortSel').value = savedSort;
var savedDmg = localStorage.getItem('firecities-dmg');
if (savedDmg) document.getElementById('dmgSel').value = savedDmg;
var savedDensity = localStorage.getItem('firecities-density');
if (savedDensity) document.getElementById('densitySlider').value = savedDensity;
} catch(e) {}
// ── Status ─────────────────────────────────────────────────────────────────
function loadStatus() {
fetch('/api/status').then(function(r){return r.json();}).then(function(d){
if (!d.ok) {
document.getElementById('noBanner').style.display = 'block';
return;
}
dataOk = true;
document.getElementById('noBanner').style.display = 'none';
document.getElementById('sTotal').textContent = Number(d.totalRecords).toLocaleString();
document.getElementById('sDestroyed').textContent = Number(d.destroyedCount).toLocaleString();
document.getElementById('sCities').textContent = Number(d.totalCities).toLocaleString();
document.getElementById('sSource').textContent = 'CAL FIRE DINS';
document.getElementById('sSource').title = d.source;
loadCounties();
loadPage(0);
}).catch(function(){
document.getElementById('noBanner').style.display = 'block';
});
}
function loadCounties() {
fetch('/api/counties').then(function(r){return r.json();}).then(function(d){
var sel = document.getElementById('countySel');
d.counties.forEach(function(c){
var o = document.createElement('option'); o.value = c; o.textContent = c;
sel.appendChild(o);
});
}).catch(function(){});
}
// ── Query params ───────────────────────────────────────────────────────────
function getParams(off) {
var p = new URLSearchParams();
p.set('sort', document.getElementById('sortSel').value);
p.set('limit', PAGE_SIZE);
p.set('offset', off || 0);
var q = document.getElementById('search').value.trim();
if (q) p.set('q', q);
var dmg = document.getElementById('dmgSel').value;
if (dmg) p.set('damage', dmg);
var county = document.getElementById('countySel').value;
if (county) p.set('county', county);
return p.toString();
}
// ── Load page ──────────────────────────────────────────────────────────────
window.__fcLoadPage = function(off){ loadPage(off); }; // expose for applyFilters (href-drill)
function loadPage(off) {
if (!dataOk) return;
document.getElementById('loadingEl').style.display = 'block';
document.getElementById('gridEl').style.opacity = '0.4';
offset = off || 0;
fetch('/api/communities?' + getParams(offset))
.then(function(r){return r.json();})
.then(function(d){
document.getElementById('loadingEl').style.display = 'none';
document.getElementById('gridEl').style.opacity = '1';
totalCount = d.total;
renderGrid(d.communities);
renderPager(d.total, d.offset, d.limit);
var cl = document.getElementById('countLine');
cl.innerHTML = '<strong>' + Number(d.total).toLocaleString() + '</strong> communities' +
(d.total !== d.communities.length + d.offset ? ' · showing ' + (d.offset+1) + '–' + (d.offset + d.communities.length) : '');
})
.catch(function(e){
document.getElementById('loadingEl').style.display = 'none';
document.getElementById('gridEl').innerHTML = '<div class="error-msg">Error loading data: ' + e.message + '</div>';
});
}
// ── Render ─────────────────────────────────────────────────────────────────
function dmgBadge(label, count, cls) {
if (!count) return '';
return '<span class="badge ' + cls + '">' + label + ' ' + Number(count).toLocaleString() + '</span>';
}
function renderGrid(communities) {
var el = document.getElementById('gridEl');
if (!communities.length) {
el.innerHTML = '<div class="loading">No communities found.</div>';
return;
}
el.innerHTML = communities.map(function(c) {
var destroyed = c.counts['Destroyed (>50%)'] || 0;
var major = c.counts['Major (25-50%)'] || 0;
var minor = c.counts['Minor (10-25%)'] || 0;
var affected = c.counts['Affected (>0-10%)'] || 0;
var incidents = (c.incidents || []).slice(0, 3).join(', ') + (c.incidents && c.incidents.length > 3 ? '…' : '');
var lastFire = c.lastDate ? c.lastDate.slice(0,4) + ' fire' : '';
return '<div class="card" data-city="' + encodeURIComponent(c.city) + '" onclick="openCity(this)">' +
'<span class="total-badge">' + Number(c.total).toLocaleString() + ' total</span>' +
'<div class="city-name">' + drill('city', c.city) + '</div>' +
'<div class="county">' + drill('county', c.county || '', esc(c.county || '') + (c.county ? ' County' : '')) + '</div>' +
'<div class="dmg-row">' +
dmgBadge('Destroyed', destroyed, 'destroyed') +
dmgBadge('Major', major, 'major') +
dmgBadge('Minor', minor, 'minor') +
dmgBadge('Affected', affected, 'affected') +
'</div>' +
(incidents ? '<div class="incidents">' + esc(incidents) + '</div>' : '') +
(lastFire ? '<div class="last-fire">Last fire: ' + lastFire + '</div>' : '') +
'</div>';
}).join('');
}
function renderPager(total, off, limit) {
var pg = document.getElementById('pager');
var pgInfo = document.getElementById('pgInfo');
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
var pages = Math.ceil(total / limit);
var cur = Math.floor(off / limit) + 1;
if (pages <= 1) { pg.style.display = 'none'; return; }
pg.style.display = 'flex';
pgInfo.textContent = 'Page ' + cur + ' of ' + pages;
prevBtn.disabled = off === 0;
nextBtn.disabled = off + limit >= total;
}
// ── Modal ──────────────────────────────────────────────────────────────────
window.openCity = function(card) {
var city = decodeURIComponent(card.dataset.city);
openCity = city;
document.getElementById('modalTitle').textContent = '🔥 ' + city;
document.getElementById('modalBody').innerHTML = '<div class="loading">Loading structures…</div>';
document.getElementById('modalOverlay').classList.add('open');
fetch('/api/structures?city=' + encodeURIComponent(city) + '&limit=200')
.then(function(r){return r.json();})
.then(function(d){
var body = document.getElementById('modalBody');
var html = '<p style="color:var(--mut);font-size:13px;margin:0 0 14px">' +
'<strong>' + d.total.toLocaleString() + '</strong> structures in ' + esc(city) +
(d.total > 200 ? ' (showing first 200)' : '') + '</p>';
// Damage summary
var dmgMap = {};
d.structures.forEach(function(s){ dmgMap[s.damage] = (dmgMap[s.damage]||0)+1; });
var summary = Object.entries(dmgMap).sort(function(a,b){return b[1]-a[1];})
.map(function(e){ return esc(e[0]) + ': ' + e[1].toLocaleString(); }).join(' · ');
html += '<p style="font-size:12px;color:var(--mut);margin:0 0 14px">' + summary + '</p>';
html += '<div class="struct-list">' +
d.structures.map(function(s){
var addr = [s.structureType].filter(Boolean).join(' ');
var dmgColor = s.damage === 'Destroyed (>50%)' ? '#ff6b47' :
s.damage === 'Major (25-50%)' ? '#ffc107' :
s.damage === 'Minor (10-25%)' ? '#64b5f6' : 'var(--mut)';
return '<div class="struct-row">' +
'<span class="addr">' + esc(s.structureType || 'Structure') + '</span> ' +
'<span style="color:' + dmgColor + ';font-weight:600;font-size:12px">' + esc(s.damage || '') + '</span>' +
'<div class="meta">' + [s.incident, s.incidentDate ? s.incidentDate.slice(0,4) : null, s.county].filter(Boolean).join(' · ') + '</div>' +
'</div>';
}).join('') + '</div>';
body.innerHTML = html;
})
.catch(function(e){
document.getElementById('modalBody').innerHTML = '<div class="error-msg">Error: ' + e.message + '</div>';
});
};
document.getElementById('modalClose').onclick = function(){
document.getElementById('modalOverlay').classList.remove('open');
};
document.getElementById('modalOverlay').onclick = function(e){
if (e.target === this) this.classList.remove('open');
};
document.addEventListener('keydown', function(e){
if (e.key === 'Escape') document.getElementById('modalOverlay').classList.remove('open');
});
// ── Controls ───────────────────────────────────────────────────────────────
document.getElementById('search').addEventListener('input', function(){
clearTimeout(debounceT);
debounceT = setTimeout(function(){ loadPage(0); }, 300);
});
['sortSel', 'dmgSel', 'countySel'].forEach(function(id){
document.getElementById(id).addEventListener('change', function(){
try { localStorage.setItem('firecities-' + id.replace('Sel',''), this.value); } catch(e){}
loadPage(0);
});
});
document.getElementById('densitySlider').addEventListener('input', function(){
var v = this.value;
document.documentElement.style.setProperty('--cols', v);
try { localStorage.setItem('firecities-density', v); localStorage.setItem('firecities-cols', v); } catch(e){}
});
// Set initial density
var initDensity = document.getElementById('densitySlider').value;
document.documentElement.style.setProperty('--cols', initDensity);
// Pager
document.getElementById('prevBtn').onclick = function(){ if (offset > 0) loadPage(offset - PAGE_SIZE); };
document.getElementById('nextBtn').onclick = function(){ loadPage(offset + PAGE_SIZE); };
// Theme toggle
(function(){
var btn = document.getElementById('themeBtn');
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
btn.textContent = cur === 'dark' ? '☀ Light' : '🌙 Dark';
btn.onclick = function(){
cur = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', cur);
btn.textContent = cur === 'dark' ? '☀ Light' : '🌙 Dark';
try { localStorage.setItem('firecities-theme', cur); } catch(e){}
};
})();
// ── Helpers ────────────────────────────────────────────────────────────────
function esc(s) {
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// ── Init ───────────────────────────────────────────────────────────────────
readURL(); // restore URL filter state (href-drill TK-10903)
loadStatus();
})();
</script>
</body>
</html>