← 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 (&gt;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 (&gt;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 (&gt;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  // ── Init ───────────────────────────────────────────────────────────────────
  readURL();  // restore URL filter state (href-drill TK-10903)
  loadStatus();
})();
</script>
</body>
</html>