← back to La Socrata Ingester

viewer/public/index.html

646 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LA Building Permits</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
  :root{
    --bg:#f6f7f9; --panel:#fff; --ink:#1a1f24; --muted:#6b7480; --line:#e3e7ec;
    --teal:#0f766e; --teal-soft:#d7efec; --accent:#0e7490; --rowh:34px; --pad:6px 10px; --cols:4;
  }
  *{box-sizing:border-box}
  body{margin:0;font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    color:var(--ink);background:var(--bg);height:100vh;overflow:hidden}
  .app{display:grid;grid-template-columns:250px 1fr;grid-template-rows:auto 1fr auto;height:100vh}
  footer.disclaimer{grid-column:1/3;border-top:1px solid var(--line);background:var(--panel);
    padding:6px 16px;font-size:11px;color:var(--muted);line-height:1.5}
  footer.disclaimer a{color:var(--accent);text-decoration:none}
  footer.disclaimer a:hover{text-decoration:underline}
  footer.disclaimer summary{cursor:pointer;list-style:none;outline:none}
  footer.disclaimer summary::-webkit-details-marker{display:none}
  footer.disclaimer summary b{color:var(--teal)}
  footer.disclaimer .full{margin-top:6px;max-width:940px}
  header{grid-column:1/3;display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);
    border-bottom:1px solid var(--line);flex-wrap:wrap}
  header h1{font-size:15px;margin:0;font-weight:650;letter-spacing:-.2px}
  header h1 b{color:var(--teal)}
  .count{color:var(--muted);font-size:12px}
  .grow{flex:1}
  input,select{font:inherit;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
  input#q{width:210px}
  .ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
  .views{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
  .views button{border:none;background:#fff;padding:6px 12px;font:inherit;cursor:pointer;color:var(--muted);border-left:1px solid var(--line)}
  .views button:first-child{border-left:none}
  .views button.on{background:var(--teal);color:#fff;font-weight:600}
  aside{background:var(--panel);border-right:1px solid var(--line);overflow:auto;padding:8px 0}
  .facet{border-bottom:1px solid var(--line)}
  .facet h3{margin:0;padding:9px 14px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
    cursor:pointer;display:flex;justify-content:space-between;user-select:none}
  .facet h3::after{content:"▾";font-size:9px;opacity:.5}
  .facet.collapsed h3::after{content:"▸"}
  .facet ul{list-style:none;margin:0;padding:0 0 6px;max-height:220px;overflow:auto}
  .facet.collapsed ul{display:none}
  .facet li{display:flex;justify-content:space-between;gap:8px;padding:4px 14px;cursor:pointer;font-size:12px}
  .facet li:hover{background:#f0f3f5}
  .facet li.active{background:var(--teal-soft);color:var(--teal);font-weight:600}
  .facet li .c{color:var(--muted);font-variant-numeric:tabular-nums;font-size:11px}
  .facet li.active .c{color:var(--teal)}
  main{overflow:auto;position:relative}
  /* list/table */
  table{border-collapse:separate;border-spacing:0;width:100%;background:var(--panel)}
  thead th{position:sticky;top:0;z-index:2;background:#eef1f4;border-bottom:1px solid var(--line);
    text-align:left;padding:var(--pad);font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#495057;
    cursor:pointer;white-space:nowrap;user-select:none}
  thead th:hover{background:#e5e9ee}
  thead th.drag{opacity:.4}
  thead th .arrow{color:var(--teal);margin-left:3px}
  thead th .grip{color:#b7bfc7;margin-right:5px;cursor:grab}
  .filterrow th{position:sticky;top:28px;z-index:1;background:#f6f7f9;border-bottom:1px solid var(--line);
    padding:3px 6px;cursor:default}
  .filterrow th:hover{background:#f6f7f9}
  .filterrow input{width:100%;min-width:52px;padding:3px 6px;font-size:11px;border:1px solid var(--line);
    border-radius:5px;background:#fff;color:var(--ink)}
  .filterrow input:focus{outline:none;border-color:var(--teal)}
  .filterrow th.nofilter{background:#f6f7f9}
  tbody td{padding:var(--pad);border-bottom:1px solid var(--line);white-space:nowrap;height:var(--rowh);
    max-width:340px;overflow:hidden;text-overflow:ellipsis}
  tbody tr:hover{background:#f0f9f8;cursor:pointer}
  tbody td.swatch-cell{border-left:4px solid var(--sw,transparent)}
  .num{text-align:right;font-variant-numeric:tabular-nums}
  .addr{font-weight:600}
  .badge{display:inline-block;padding:1px 7px;border-radius:20px;font-size:11px;font-weight:600;background:#eef1f4;color:#495057}
  .score{display:inline-block;min-width:34px;text-align:center;padding:2px 7px;border-radius:6px;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}
  .pill{padding:1px 8px;border-radius:20px;font-size:11px;font-weight:600}
  .pill.Issued{background:#dcfce7;color:#15803d}.pill.Finaled{background:#f1f5f9;color:#64748b}
  .pill.Expired{background:#fee2e2;color:#b91c1c}.pill.other{background:#f1f5f9;color:#64748b}
  /* grid */
  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:14px}
  .card{border:1px solid var(--line);border-left:5px solid var(--sw,#ccc);border-radius:10px;background:#fff;padding:12px 13px;cursor:pointer;transition:box-shadow .1s}
  .card:hover{box-shadow:0 4px 14px rgba(16,24,40,.1)}
  .card .top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
  .card .a{font-weight:650;font-size:13px;line-height:1.25}
  .card .meta{color:var(--muted);font-size:11px;margin-top:5px;display:flex;flex-wrap:wrap;gap:5px 10px}
  .card .money{font-weight:700;color:var(--teal);font-variant-numeric:tabular-nums}
  .card .when{color:var(--muted);font-size:11px;margin-top:6px}
  /* map */
  #map{height:100%;width:100%}
  .mapwrap{position:absolute;inset:0}
  .mapjump{position:absolute;top:10px;left:52px;z-index:500;background:#fff;border:1px solid var(--line);border-radius:8px;
    box-shadow:0 2px 8px rgba(0,0,0,.12);padding:5px 8px;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
  .mapjump select{font-size:11px;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);max-width:150px}
  .legend{position:absolute;bottom:14px;right:14px;z-index:500;background:#fff;border:1px solid var(--line);border-radius:8px;padding:8px 11px;font-size:11px;box-shadow:0 2px 8px rgba(0,0,0,.12)}
  .legend b{display:block;margin-bottom:5px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
  .legend .row{display:flex;align-items:center;gap:6px;margin:2px 0}
  .legend .dot{width:11px;height:11px;border-radius:50%}
  .loadmore{display:block;margin:14px auto 40px;padding:9px 22px;border:1px solid var(--teal);background:#fff;color:var(--teal);
    border-radius:8px;font-weight:600;cursor:pointer}
  .loadmore:hover{background:var(--teal);color:#fff}
  /* modal */
  .modal-bg{position:fixed;inset:0;background:rgba(20,25,30,.35);display:none;z-index:1000}
  .modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(680px,92vw);max-height:86vh;overflow:auto;
    background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);z-index:1001;display:none}
  .modal header{display:flex;justify-content:space-between;align-items:center;position:sticky;top:0}
  .modal h2{font-size:15px;margin:0}
  .modal .x{cursor:pointer;font-size:20px;color:var(--muted);border:none;background:none}
  .modal .body{padding:14px 18px}
  .kv{display:grid;grid-template-columns:150px 1fr;gap:4px 14px;font-size:13px;margin-bottom:14px}
  .kv .k{color:var(--muted)}
  .modal a.map{display:inline-block;margin:2px 0 12px;color:var(--teal);font-weight:600;text-decoration:none}
  pre{background:#0f1720;color:#c5e4de;padding:12px;border-radius:8px;overflow:auto;font-size:11px;max-height:280px}
  .rawtoggle{cursor:pointer;color:var(--accent);font-size:12px;user-select:none}
  .hidden{display:none!important}
  /* status overlay: loading + empty state */
  .overlay{position:absolute;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
    background:rgba(246,247,249,.72);backdrop-filter:blur(1px)}
  .overlay .box{text-align:center;color:var(--muted);font-size:13px}
  .overlay .box .big{font-size:15px;font-weight:650;color:var(--ink);margin-bottom:4px}
  .overlay .box .sub{margin-bottom:10px}
  .overlay .box button{border:1px solid var(--teal);background:#fff;color:var(--teal);border-radius:8px;
    padding:6px 16px;font:inherit;font-weight:600;cursor:pointer}
  .overlay .box button:hover{background:var(--teal);color:#fff}
  .spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--teal);border-radius:50%;
    margin:0 auto 10px;animation:spin .7s linear infinite}
  @keyframes spin{to{transform:rotate(360deg)}}
  /* column show/hide popover */
  .colspop{position:absolute;top:34px;right:0;z-index:70;background:#fff;border:1px solid var(--line);border-radius:8px;
    box-shadow:0 8px 24px rgba(16,24,40,.14);padding:6px;min-width:186px;max-height:340px;overflow:auto}
  .colspop .hd{display:flex;justify-content:space-between;align-items:center;padding:4px 8px 6px;border-bottom:1px solid var(--line);margin-bottom:4px}
  .colspop .hd span:first-child{color:var(--muted);font-size:11px;letter-spacing:.05em}
  .colspop .hd a{color:var(--accent);font-size:11px;cursor:pointer;text-decoration:none}
  .colspop .hd a:hover{text-decoration:underline}
  .colspop label{display:flex;align-items:center;gap:7px;padding:4px 8px;font-size:12px;cursor:pointer;border-radius:6px;white-space:nowrap}
  .colspop label:hover{background:#f0f3f5}
  kbd{font:11px/1 ui-monospace,Menlo,monospace;background:#eef1f4;border:1px solid var(--line);border-bottom-width:2px;
    border-radius:4px;padding:1px 5px;color:#495057}
  #savedpop{min-width:236px}
  .colspop .savedrow{display:flex;justify-content:space-between;align-items:center}
  .colspop .savedrow a.savedapply{flex:1;padding:5px 8px;cursor:pointer;border-radius:6px;color:var(--ink);text-decoration:none;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .colspop .savedrow a.savedapply:hover{background:#f0f3f5}
  .colspop .savedrow a.saveddel{padding:5px 8px;color:var(--muted);cursor:pointer;text-decoration:none}
  .colspop .savedrow a.saveddel:hover{color:#dc2626}
</style>
</head>
<body>
<div class="app">
  <header>
    <h1>LA <b>Building Permits</b></h1>
    <input id="q" type="search" placeholder="Search address…  (press / )" autocomplete="off">
    <span class="count" id="count">…</span>
    <span class="grow"></span>
    <div class="views" id="views">
      <button data-v="list" class="on">☰ List</button>
      <button data-v="grid">▦ Grid</button>
      <button data-v="map">🗺 Map</button>
    </div>
    <span class="ctl">Color <select id="colorby">
      <option value="lead_score">Lead score</option>
      <option value="status">Status</option>
      <option value="project_value">Project $</option>
      <option value="work_type">Work type</option>
    </select></span>
    <span class="ctl" id="densctl">Density <input id="density" type="range" min="26" max="46" value="34"></span>
    <span class="ctl" id="savedwrap" style="position:relative"><button id="savedbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Save the current filters/sort/layout as a named view, or apply a saved one">📑 Views</button><div id="savedpop" class="colspop hidden"></div></span>
    <span class="ctl" id="colswrap" style="position:relative"><button id="colsbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Show / hide table columns">▦ Columns</button><div id="colspop" class="colspop hidden"></div></span>
    <span class="ctl"><button id="csv" class="loadmore" style="margin:0;padding:5px 12px" title="Download the current filtered + sorted results as a CSV (up to 5,000 rows)">⬇ CSV</button></span>
    <span class="ctl"><button id="copylink" class="loadmore" style="margin:0;padding:5px 12px" title="Copy a shareable link to this exact view (filters + sort + view + color)">🔗 Copy link</button></span>
    <span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
  </header>
  <aside id="rail"></aside>
  <main>
    <div id="overlay" class="overlay hidden"></div>
    <div id="listview">
      <table id="tbl"><thead><tr id="head"></tr><tr id="filterrow" class="filterrow"></tr></thead><tbody id="rows"></tbody></table>
      <div id="dls" hidden></div>
      <button class="loadmore" id="more" style="display:none">Load more</button>
    </div>
    <div id="gridview" class="hidden">
      <div class="grid" id="cards"></div>
      <button class="loadmore" id="more2" style="display:none">Load more</button>
    </div>
    <div id="mapview" class="mapwrap hidden">
      <div id="map"></div>
      <div class="mapjump" id="mapjump">
        <span>Jump to</span>
        <select id="jumpcd"><option value="">CD…</option></select>
        <select id="jumpzip"><option value="">ZIP…</option></select>
      </div>
      <div class="legend" id="legend"></div>
    </div>
  </main>
  <footer class="disclaimer">
    <details>
      <summary>ⓘ Data from LA City &amp; LA County public records — provided &ldquo;as is,&rdquo; not independently verified. <b>Sources &amp; terms&nbsp;▸</b></summary>
      <div class="full">
        <b>Sources.</b> Building permits: City of Los Angeles, Dept. of Building &amp; Safety, via the
        <a href="https://data.lacity.org" target="_blank" rel="noopener noreferrer">LA City Open Data portal</a>.
        Parcel &amp; assessment attributes: County of Los Angeles Office of the Assessor / Enterprise GIS.<br>
        <b>Terms.</b> Used under the
        <a href="https://egis-lacounty.hub.arcgis.com/pages/terms-of-use" target="_blank" rel="noopener noreferrer">LA County eGIS Terms of Use</a>
        and the LA City Open Data terms. The data is provided <b>&ldquo;as is,&rdquo; with no warranty</b> of accuracy, completeness, or fitness for any purpose, and must not be relied upon for legal, engineering, lending, or surveying decisions.<br>
        <b>No endorsement.</b> This tool is not affiliated with, sponsored by, or endorsed by the City or County of Los Angeles.<br>
        <b>Site.</b> <a href="/terms.html">Terms of Use</a> &middot; <a href="/privacy.html">Privacy Policy</a>. Map tiles &copy; OpenStreetMap contributors.
      </div>
    </details>
    <span style="white-space:nowrap"><a href="/terms.html">Terms</a> &middot; <a href="/privacy.html">Privacy</a></span>
  </footer>
</div>

<div class="modal-bg" id="mbg"></div>
<div class="modal" id="modal">
  <header style="padding:14px 18px;border-bottom:1px solid var(--line)">
    <h2 id="mtitle">Permit</h2><button class="x" id="mx">×</button>
  </header>
  <div class="body" id="mbody"></div>
</div>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const $ = s => document.querySelector(s);
const LS = { get:(k,d)=>{try{const v=localStorage.getItem('lap_'+k);return v==null?d:JSON.parse(v)}catch{return d}}, set:(k,v)=>localStorage.setItem('lap_'+k,JSON.stringify(v)) };
// Absolute same-origin API base — fetch() rejects relative paths when the page URL carries embedded Basic-Auth creds ("URL that includes credentials"); location.origin is credential-free.
const api = path => location.origin + path;

const ALLCOLS = [
  {key:'lead_score', label:'Rank', type:'score'},
  {key:'issued', label:'Issued', type:'date'},
  {key:'address', label:'Address', type:'addr'},
  {key:'zip', label:'ZIP', type:'t'},
  {key:'cd', label:'CD', type:'t'},
  {key:'work_type', label:'Work Type', type:'badge'},
  {key:'building_type', label:'Building Type', type:'badge'},
  {key:'status', label:'Status', type:'status'},
  {key:'project_value', label:'Project $', type:'money'},
  {key:'assessed_value', label:'Assessed $', type:'money'},
  {key:'year_built', label:'Yr Built', type:'t'},
  {key:'sqft', label:'SqFt', type:'int'},
  {key:'use_desc', label:'Use', type:'t'},
  {key:'era', label:'Era', type:'t'},
  {key:'permit_nbr', label:'Permit #', type:'t'},
];
let colOrder = LS.get('cols', ALLCOLS.map(c=>c.key));
if(!Array.isArray(colOrder)||colOrder.length!==ALLCOLS.length) colOrder = ALLCOLS.map(c=>c.key);
let colHidden = new Set(LS.get('hidden', []));
const colDef = k => ALLCOLS.find(c=>c.key===k);
const cols = () => colOrder.map(colDef).filter(c=>c && !colHidden.has(c.key));

let state = {
  filters:{}, sort:LS.get('sort',{col:'lead_score',dir:'desc'}), page:1, rows:[], total:0,
  view:LS.get('view','list'), colorBy:LS.get('colorby','lead_score'),
};

const FACETS = [['era','Era'],['work_type','Work Type'],['building_type','Building Type'],['status','Status'],['cd','Council District'],['zip','ZIP']];
const money = v => v==null?'':'$'+Number(v).toLocaleString(undefined,{maximumFractionDigits:0});

// ---- COLOR ID: one color model driving list / grid / map ----
const scoreColor = s => s>=75?'#0f766e':s>=55?'#16a34a':s>=35?'#f59e0b':'#94a3b8';
const WORK_COLORS = {'Bldg-New':'#0f766e','Bldg-Addition':'#2563eb','Bldg-Alter/Repair':'#f59e0b','Swimming-Pool/Spa':'#06b6d4','Bldg-Demolition':'#dc2626','Grading':'#9333ea'};
function valueColor(v){ v=Number(v)||0; return v>=2e6?'#0f766e':v>=1e6?'#16a34a':v>=500e3?'#65a30d':v>=250e3?'#f59e0b':v>=100e3?'#f97316':'#94a3b8'; }
function statusColor(s){ return /Issued/.test(s)?'#16a34a':/Final/.test(s)?'#64748b':/Expired/.test(s)?'#dc2626':'#94a3b8'; }
function colorFor(r){
  switch(state.colorBy){
    case 'status': return statusColor(r.status);
    case 'project_value': return valueColor(r.project_value);
    case 'work_type': return WORK_COLORS[r.work_type]||'#94a3b8';
    default: return scoreColor(r.lead_score);
  }
}
function legendItems(){
  switch(state.colorBy){
    case 'status': return [['Issued','#16a34a'],['Finaled','#64748b'],['Expired','#dc2626'],['Other','#94a3b8']];
    case 'project_value': return [['≥ $2M','#0f766e'],['$1–2M','#16a34a'],['$500k–1M','#65a30d'],['$250–500k','#f59e0b'],['$100–250k','#f97316'],['< $100k','#94a3b8']];
    case 'work_type': return Object.entries(WORK_COLORS).map(([k,v])=>[k,v]).concat([['Other','#94a3b8']]);
    default: return [['75–100','#0f766e'],['55–74','#16a34a'],['35–54','#f59e0b'],['0–34','#94a3b8']];
  }
}

function qs(extra={}){
  const p=new URLSearchParams();
  for(const [k,v] of Object.entries(state.filters)) if(v) p.set(k,v);
  p.set('sort',state.sort.col); p.set('dir',state.sort.dir);
  for(const [k,v] of Object.entries(extra)) p.set(k,v);
  return p.toString();
}

async function loadFacets(){
  const d = await (await fetch(api('/api/facets?'+qs()))).json();
  facetData = d; renderDatalists(); // feed the per-column filter datalists
  const rail = $('#rail'); const collapsed = LS.get('collapsed',{});
  rail.innerHTML = FACETS.map(([key,label])=>{
    const items=(d[key]||[]).map(o=>{
      const active = String(state.filters[key]||'')===String(o.v);
      return `<li data-k="${key}" data-v="${escapeAttr(o.v)}" class="${active?'active':''}"><span>${escapeHtml(o.v)}</span><span class="c">${Number(o.c).toLocaleString()}</span></li>`;
    }).join('');
    return `<div class="facet ${collapsed[key]?'collapsed':''}" data-f="${key}"><h3>${label}<span></span></h3><ul>${items}</ul></div>`;
  }).join('');
  rail.querySelectorAll('.facet h3').forEach(h=>h.onclick=()=>{const f=h.parentElement.dataset.f;const c=LS.get('collapsed',{});c[f]=!c[f];LS.set('collapsed',c);h.parentElement.classList.toggle('collapsed');});
  rail.querySelectorAll('li').forEach(li=>li.onclick=()=>{ const {k,v}=li.dataset; state.filters[k]=String(state.filters[k]||'')===v?'':v; reload(); });
}

// ---- status overlay (loading + empty state) ----
const overlay = () => $('#overlay');
function showLoading(){ overlay().innerHTML='<div class="box"><div class="spin"></div>Loading permits…</div>'; overlay().classList.remove('hidden'); }
function showEmpty(){ const filtered=Object.values(state.filters).some(Boolean);
  overlay().innerHTML=`<div class="box"><div class="big">No permits match these filters</div>`+
    (filtered?`<div class="sub">Try widening or clearing a filter.</div><button id="emptyreset">Clear all filters</button>`:`<div class="sub">Nothing to show.</div>`)+`</div>`;
  overlay().classList.remove('hidden');
  const b=$('#emptyreset'); if(b) b.onclick=()=>{ state.filters={}; $('#q').value=''; reload(); };
}
function hideOverlay(){ overlay().classList.add('hidden'); }

async function loadRows(append=false){
  if(!append) showLoading();
  const limit = state.view==='map' ? 1500 : 100;
  let d;
  try{ d = await (await fetch(api('/api/permits?'+qs({page:state.page,limit})))).json(); }
  catch(e){ overlay().innerHTML='<div class="box"><div class="big">Couldn’t load permits</div><div class="sub">'+escapeHtml(e.message||'network error')+'</div></div>'; overlay().classList.remove('hidden'); return; }
  (d.rows||[]).forEach(r=>{ if(r.issued==null) r.issued=r.issue_date; }); // API sends issue_date; the List/Grid/CSV column key is `issued`
  state.total = d.total;
  state.rows = append ? state.rows.concat(d.rows) : d.rows;
  $('#count').textContent = state.total.toLocaleString()+' permits'+(Object.values(state.filters).some(Boolean)?' (filtered)':'');
  if(!append && state.rows.length===0) showEmpty(); else hideOverlay();
  render();
  const hasMore = state.rows.length < state.total && state.view!=='map';
  $('#more').style.display = (state.view==='list'&&hasMore)?'block':'none';
  $('#more2').style.display = (state.view==='grid'&&hasMore)?'block':'none';
}

// ---- LIST ----
function renderHead(){
  const head=$('#head');
  head.innerHTML = cols().map(c=>{
    const arr = state.sort.col===c.key ? `<span class="arrow">${state.sort.dir==='asc'?'▲':'▼'}</span>` : '';
    return `<th draggable="true" data-k="${c.key}"><span class="grip">⠿</span>${c.label}${arr}</th>`;
  }).join('');
  head.querySelectorAll('th').forEach(th=>{
    th.onclick=e=>{ if(e.target.classList.contains('grip'))return; sortBy(th.dataset.k); };
    th.ondragstart=()=>{drag=th.dataset.k;th.classList.add('drag');};
    th.ondragend=()=>th.classList.remove('drag');
    th.ondragover=e=>e.preventDefault();
    th.ondrop=e=>{e.preventDefault();dropCol(th.dataset.k);};
  });
  renderFilterRow();
}
// per-column filters map each column to a server filter param (reuses state.filters + qs())
const COLFILTER = {
  issued:        {param:'since',        type:'date',   ph:'on/after'},
  address:       {param:'q',            type:'search', ph:'contains…'},
  zip:           {param:'zip',          type:'text',   ph:'ZIP',  list:'zip'},
  cd:            {param:'cd',            type:'text',   ph:'CD',   list:'cd'},
  work_type:     {param:'work_type',    type:'text',   ph:'any',  list:'work_type'},
  building_type: {param:'building_type',type:'text',   ph:'any',  list:'building_type'},
  status:        {param:'status',       type:'text',   ph:'any',  list:'status'},
  era:           {param:'era',          type:'text',   ph:'any',  list:'era'},
  project_value: {param:'min_value',    type:'number', ph:'≥ $'},
};
let facetData = {};
function renderFilterRow(){
  const fr=$('#filterrow'); if(!fr) return;
  const act=document.activeElement, actP=(act&&act.dataset&&act.dataset.p)?act.dataset.p:null;
  const selPos = actP ? act.selectionStart : null;
  fr.innerHTML = cols().map(c=>{
    const f=COLFILTER[c.key];
    if(!f) return `<th class="nofilter"></th>`;
    const v = state.filters[f.param]!=null ? escapeAttr(state.filters[f.param]) : '';
    return `<th><input data-p="${f.param}" type="${f.type}" placeholder="${f.ph}" value="${v}"${f.list?` list="dl_${f.param}"`:''}></th>`;
  }).join('');
  fr.querySelectorAll('input').forEach(inp=>{
    const debounce = (inp.type==='date') ? 0 : 300;
    inp.oninput=()=>{ clearTimeout(inp._t); inp._t=setTimeout(()=>{
      const p=inp.dataset.p, v=inp.value.trim();
      if(v) state.filters[p]=v; else delete state.filters[p];
      if(p==='q') $('#q').value=v; // keep top search box in sync
      reload();
    }, debounce); };
  });
  if(actP){ const el=fr.querySelector(`input[data-p="${actP}"]`); if(el){ el.focus(); try{ el.setSelectionRange(selPos,selPos); }catch(e){} } }
  renderDatalists();
}
function renderDatalists(){
  const h=$('#dls'); if(!h) return;
  const mk=(param,key)=>`<datalist id="dl_${param}">`+((facetData[key]||[]).map(o=>`<option value="${escapeAttr(o.v)}">`).join(''))+`</datalist>`;
  h.innerHTML = mk('zip','zip')+mk('cd','cd')+mk('work_type','work_type')+mk('building_type','building_type')+mk('status','status')+mk('era','era');
}
let drag=null;
function dropCol(target){ if(!drag||drag===target)return; const a=colOrder.indexOf(drag),b=colOrder.indexOf(target);
  colOrder.splice(a,1); colOrder.splice(b,0,drag); LS.set('cols',colOrder); drag=null; renderHead(); renderBody(); }
function sortBy(col){
  if(state.sort.col===col) state.sort.dir = state.sort.dir==='asc'?'desc':'asc';
  else state.sort = {col, dir:['address','zip','use_desc','permit_nbr','work_type','building_type','status','era'].includes(col)?'asc':'desc'};
  LS.set('sort',state.sort); state.page=1; loadRows(false);
}
function cell(c,r){
  const v=r[c.key];
  switch(c.type){
    case 'score': return `<span class="score" style="background:${scoreColor(v)}">${v==null?'':v}</span>`;
    case 'date': return v?new Date(v).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'';
    case 'money': return `<span class="num">${money(v)}</span>`;
    case 'int': return `<span class="num">${v==null?'':Number(v).toLocaleString()}</span>`;
    case 'addr': return `<span class="addr">${escapeHtml(v||'')}</span>`;
    case 'badge': return v?`<span class="badge">${escapeHtml(v)}</span>`:'';
    case 'status': {const cls=/Issued/.test(v)?'Issued':/Final/.test(v)?'Finaled':/Expired/.test(v)?'Expired':'other';return v?`<span class="pill ${cls}">${escapeHtml(v)}</span>`:''}
    default: return escapeHtml(v==null?'':String(v));
  }
}
function renderBody(){
  const cs=cols();
  $('#rows').innerHTML = state.rows.map(r=>
    `<tr data-n="${escapeAttr(r.permit_nbr)}">`+cs.map((c,i)=>
      `<td class="${i===0?'swatch-cell':''}" ${i===0?`style="--sw:${colorFor(r)}"`:''}>${cell(c,r)}</td>`).join('')+`</tr>`
  ).join('');
  $('#rows').querySelectorAll('tr').forEach(tr=>tr.onclick=()=>openDetail(tr.dataset.n));
}

// ---- GRID ----
function renderCards(){
  $('#cards').innerHTML = state.rows.map(r=>`
    <div class="card" data-n="${escapeAttr(r.permit_nbr)}" style="--sw:${colorFor(r)}">
      <div class="top"><div class="a">${escapeHtml(r.address||'')}</div>
        <span class="score" style="background:${scoreColor(r.lead_score)}">${r.lead_score??''}</span></div>
      <div class="meta">${r.work_type?`<span class="badge">${escapeHtml(r.work_type)}</span>`:''}
        ${r.building_type?`<span>${escapeHtml(r.building_type)}</span>`:''}<span>CD ${escapeHtml(r.cd||'—')}</span><span>${escapeHtml(r.zip||'')}</span></div>
      <div class="meta"><span class="money">${money(r.project_value)}</span>${r.assessed_value?`<span>assessed ${money(r.assessed_value)}</span>`:''}${r.year_built?`<span>built ${escapeHtml(r.year_built)}</span>`:''}</div>
      <div class="when">${r.issued?new Date(r.issued).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):''} · ${escapeHtml(r.status||'')}</div>
    </div>`).join('');
  $('#cards').querySelectorAll('.card').forEach(c=>c.onclick=()=>openDetail(c.dataset.n));
}

// ---- MAP ----
let map=null, layer=null;
function renderMap(){
  if(!map){
    map = L.map('map',{preferCanvas:true}).setView([34.02,-118.35],10);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
      attribution:'© OpenStreetMap contributors', maxZoom:19}).addTo(map);
  }
  if(layer) layer.remove();
  layer = L.layerGroup();
  const pts=[];
  for(const r of state.rows){
    if(r.lat==null||r.lon==null) continue;
    const m = L.circleMarker([r.lat,r.lon],{radius:5,color:'#fff',weight:1,fillColor:colorFor(r),fillOpacity:.9});
    m.on('click',()=>openDetail(r.permit_nbr));
    m.bindTooltip(`${escapeHtml(r.address||'')} · ${money(r.project_value)} · score ${r.lead_score??''}`);
    layer.addLayer(m); pts.push([r.lat,r.lon]);
  }
  layer.addTo(map);
  if(pts.length) map.fitBounds(pts,{padding:[30,30],maxZoom:14});
  $('#legend').innerHTML = `<b>${$('#colorby').selectedOptions[0].text} · ${state.rows.filter(r=>r.lat).length} shown</b>`+
    legendItems().map(([l,c])=>`<div class="row"><span class="dot" style="background:${c}"></span>${escapeHtml(l)}</div>`).join('');
  renderMapJump();
  setTimeout(()=>map.invalidateSize(),50);
}
// ---- CD / ZIP quick-jump on the map (filters + the existing fitBounds flies there) ----
let allFacets = {}; // stable full CD/ZIP lists (unfiltered), so the jump menu always offers every value
function renderMapJump(){
  const cd=$('#jumpcd'), zip=$('#jumpzip'); if(!cd||!zip) return;
  const jf = k => (allFacets[k]&&allFacets[k].length)?allFacets[k]:(facetData[k]||[]);
  const fill=(sel,items,label,cur)=>{ sel.innerHTML=`<option value="">${label}</option>`+
    (items||[]).map(o=>`<option value="${escapeAttr(o.v)}" ${String(cur||'')===String(o.v)?'selected':''}>${escapeHtml(o.v)} (${Number(o.c).toLocaleString()})</option>`).join(''); };
  fill(cd, jf('cd'), 'CD…', state.filters.cd);
  fill(zip, jf('zip'), 'ZIP…', state.filters.zip);
}
$('#jumpcd').onchange=e=>{ const v=e.target.value; if(v) state.filters.cd=v; else delete state.filters.cd; reload(); };
$('#jumpzip').onchange=e=>{ const v=e.target.value; if(v) state.filters.zip=v; else delete state.filters.zip; reload(); };

function syncURL(){ // mirror filters+sort+view+color into the URL so any view is shareable/bookmarkable
  const p=new URLSearchParams();
  for(const [k,v] of Object.entries(state.filters)) if(v) p.set(k,v);
  if(state.sort&&state.sort.col){ p.set('sort',state.sort.col); p.set('dir',state.sort.dir); }
  if(state.view&&state.view!=='list') p.set('view',state.view);
  if(state.colorBy&&state.colorBy!=='lead_score') p.set('color',state.colorBy);
  const dens=$('#density').value; if(dens && dens!=='34') p.set('d',dens); // density
  const visKeys=cols().map(c=>c.key), defKeys=ALLCOLS.map(c=>c.key); // visible column order (encodes both hidden + reorder)
  if(visKeys.join(',')!==defKeys.join(',')) p.set('cols',visKeys.join(','));
  const s=p.toString();
  try{ history.replaceState(null,'', s?('?'+s):location.pathname); }catch(e){}
}
function render(){
  syncURL();
  $('#listview').classList.toggle('hidden', state.view!=='list');
  $('#gridview').classList.toggle('hidden', state.view!=='grid');
  $('#mapview').classList.toggle('hidden', state.view!=='map');
  $('#densctl').style.display = state.view==='map'?'none':'flex';
  $('#colswrap').style.display = state.view==='list'?'inline-flex':'none'; // columns menu is list-only
  if(state.view!=='list') $('#colspop').classList.add('hidden');
  if(state.view==='list'){ renderHead(); renderBody(); }
  else if(state.view==='grid') renderCards();
  else renderMap();
}

async function openDetail(nbr){
  const r = await (await fetch(api('/api/permit/'+encodeURIComponent(nbr)))).json();
  $('#mtitle').textContent = (r.primary_address||'Permit')+' — '+nbr;
  const rows=[
    ['Lead score', r.lead_score], ['Issued', r.issue_date?new Date(r.issue_date).toLocaleString():''],
    ['Work type', r.permit_type], ['Building type', r.permit_sub_type], ['Use', r.use_desc],
    ['Status', r.status_desc], ['Project value', money(r.valuation)], ['Council district', r.cd],
    ['ZIP', r.zip_code], ['Era', r.era], ['APN', r.apn],
    ['— Parcel (2025) —',''], ['Assessed value', money(r.parcel_assessed)], ['Year built', r.parcel_year_built],
    ['SqFt', r.parcel_sqft], ['Use type', r.parcel_use], ['Parcel address', r.parcel_address],
    ['Scope', r.work_desc],
  ].filter(([k,v])=>v!=null&&v!=='');
  const mp = (r.lat&&r.lon)?`<a class="map" target="_blank" rel="noopener" href="https://www.google.com/maps?q=${r.lat},${r.lon}">📍 Open in Google Maps</a>`:'';
  $('#mbody').innerHTML = `<div class="kv">${rows.map(([k,v])=>`<div class="k">${escapeHtml(k)}</div><div>${escapeHtml(String(v))}</div>`).join('')}</div>${mp}
    <div class="rawtoggle" id="rawt">▸ Raw record</div><pre id="raw" style="display:none">${escapeHtml(JSON.stringify(r.raw,null,2))}</pre>`;
  $('#rawt').onclick=()=>{const p=$('#raw');p.style.display=p.style.display==='none'?'block':'none';};
  $('#mbg').style.display=$('#modal').style.display='block';
}
$('#mx').onclick=$('#mbg').onclick=()=>$('#mbg').style.display=$('#modal').style.display='none';

function reload(){ state.page=1; loadFacets(); loadRows(false); }
$('#more').onclick=$('#more2').onclick=()=>{ state.page++; loadRows(true); };
let t; $('#q').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{state.filters.q=e.target.value.trim();reload();},280);};
$('#density').oninput=e=>{document.documentElement.style.setProperty('--rowh',e.target.value+'px');const c=Math.max(2,Math.round((72-e.target.value)/8));document.documentElement.style.setProperty('--cols',c);LS.set('density',e.target.value);syncURL();};
$('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
// ---- CSV export of the current filtered + sorted view (client-side, capped) ----
const CSV_CAP = 5000; // hard ceiling; if the filtered total exceeds this we export the top N and say so
const CSV_COLS = ['lead_score','issued','address','zip','cd','work_type','building_type','status','project_value','assessed_value','year_built','sqft','use_desc','era','permit_nbr'];
const CSV_HEAD = ['Lead score','Issued','Address','ZIP','CD','Work type','Building type','Status','Project value','Assessed value','Year built','SqFt','Use','Era','Permit #'];
const csvCell = v => { if(v==null) return ''; const s=String(v); return /[",\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s; };
$('#csv').onclick=async()=>{
  const btn=$('#csv'), was=btn.textContent;
  try{
    const want = Math.min(state.total||CSV_CAP, CSV_CAP);
    const rows=[]; let page=1;
    while(rows.length < want){
      btn.textContent='… '+rows.length.toLocaleString();
      const d = await (await fetch(api('/api/permits?'+qs({page,limit:200})))).json();
      if(!d.rows || !d.rows.length) break;
      d.rows.forEach(r=>{ if(r.issued==null) r.issued=r.issue_date; }); // match List/Grid: API sends issue_date
      rows.push(...d.rows);
      if(d.rows.length < 200) break; // last page
      page++;
    }
    const clip = rows.slice(0, want);
    const lines = [CSV_HEAD.join(',')].concat(clip.map(r=>CSV_COLS.map(k=>csvCell(r[k])).join(',')));
    const blob = new Blob([''+lines.join('\r\n')], {type:'text/csv;charset=utf-8;'});
    const active = Object.entries(state.filters).filter(([,v])=>v).map(([k,v])=>k+'-'+v).join('_').replace(/[^\w-]+/g,'').slice(0,60);
    const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
    a.download = 'la-permits'+(active?'_'+active:'')+'.csv'; document.body.appendChild(a); a.click();
    setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
    btn.textContent='✓ '+clip.length.toLocaleString();
    if((state.total||0) > CSV_CAP) alert('Exported the top '+CSV_CAP.toLocaleString()+' rows (by current sort) of '+state.total.toLocaleString()+' filtered permits. Narrow the filters to capture the rest.');
  }catch(e){ btn.textContent='✕ error'; console.error('CSV export failed',e); }
  setTimeout(()=>btn.textContent=was,1800);
};
$('#copylink').onclick=async()=>{ // URL already mirrors state via syncURL(); copy the live href
  syncURL();
  const btn=$('#copylink'), was=btn.textContent, url=location.href;
  try{ await navigator.clipboard.writeText(url); }
  catch{ const t=document.createElement('textarea'); t.value=url; document.body.appendChild(t); t.select();
    try{document.execCommand('copy');}catch{} t.remove(); }
  btn.textContent='✓ Copied'; setTimeout(()=>btn.textContent=was,1400);
};
$('#views').querySelectorAll('button').forEach(b=>b.onclick=()=>{
  state.view=b.dataset.v; LS.set('view',state.view);
  $('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));
  loadRows(false);
});
$('#colorby').onchange=e=>{ state.colorBy=e.target.value; LS.set('colorby',state.colorBy); render(); };

// ---- column show/hide popover ----
function renderColsMenu(){
  const pop=$('#colspop');
  pop.innerHTML = `<div class="hd"><span>COLUMNS</span><span><a id="colall">show all</a> · <a id="colreset">reset</a></span></div>`+
    colOrder.map(k=>{ const c=colDef(k); if(!c) return ''; const on=!colHidden.has(k);
      return `<label><input type="checkbox" data-k="${k}" ${on?'checked':''}>${escapeHtml(c.label)}</label>`; }).join('');
  pop.querySelectorAll('input[type=checkbox]').forEach(cb=>cb.onchange=()=>{
    const k=cb.dataset.k;
    if(cb.checked) colHidden.delete(k);
    else { if(cols().length<=1){ cb.checked=true; return; } colHidden.add(k); } // always keep >=1 column visible
    LS.set('hidden',[...colHidden]); renderHead(); renderBody(); syncURL();
  });
  $('#colall').onclick=()=>{ colHidden.clear(); LS.set('hidden',[]); renderColsMenu(); renderHead(); renderBody(); syncURL(); };
  $('#colreset').onclick=()=>{ colOrder=ALLCOLS.map(c=>c.key); colHidden.clear(); LS.set('cols',colOrder); LS.set('hidden',[]); renderColsMenu(); renderHead(); renderBody(); syncURL(); };
}
$('#colsbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#colspop');
  if(pop.classList.contains('hidden')){ renderColsMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
document.addEventListener('click',e=>{ if(!e.target.closest('#colswrap')) $('#colspop').classList.add('hidden'); });

// ---- keyboard: "/" focuses search, Esc closes modal/popover ----
document.addEventListener('keydown',e=>{
  const tag=(e.target.tagName||'').toLowerCase(), typing=tag==='input'||tag==='select'||tag==='textarea';
  if(e.key==='/' && !typing){ e.preventDefault(); $('#q').focus(); $('#q').select(); }
  else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); $('#savedpop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
});

function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
function escapeAttr(s){return escapeHtml(s).replace(/'/g,'&#39;')}

// ---- hydrate the whole view-state from a query string (shared by boot + saved views) ----
// strict=true (saved views): absent params reset to defaults, so a snapshot restores exactly.
// strict=false (boot): absent density/columns fall back to the user's persisted localStorage prefs.
function hydrateFromQuery(qstr, strict){
  const u=new URLSearchParams(qstr);
  state.filters={};
  ['q','era','work_type','building_type','status','cd','zip','min_value','since'].forEach(k=>{ if(u.get(k)) state.filters[k]=u.get(k); });
  state.view=u.get('view')||'list';
  state.colorBy=u.get('color')||'lead_score';
  state.sort=u.get('sort')?{col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'}:{col:'lead_score',dir:'desc'};
  if(u.get('cols')){ const vis=u.get('cols').split(',').filter(k=>colDef(k));
    if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); } }
  else if(strict){ colOrder=ALLCOLS.map(c=>c.key); colHidden=new Set(); }
  state._density = u.get('d') || (strict ? '34' : LS.get('density','34'));
}
function syncControls(){ // push state back into the visible UI controls
  $('#q').value = state.filters.q || '';
  const d = state._density || '34';
  document.documentElement.style.setProperty('--rowh', d+'px');
  document.documentElement.style.setProperty('--cols', Math.max(2,Math.round((72-d)/8)));
  $('#density').value = d;
  $('#colorby').value = state.colorBy;
  $('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x.dataset.v===state.view));
}

// ---- saved views: named snapshots of the full querystring ----
const getSaved = () => LS.get('saved', []);
function applySavedView(q){ hydrateFromQuery('?'+q, true); syncControls(); state.page=1; loadFacets(); loadRows(false); }
function renderSavedMenu(){
  const pop=$('#savedpop'), list=getSaved();
  pop.innerHTML = `<div class="hd"><span>SAVED VIEWS</span></div>`+
    `<div style="padding:2px 8px 8px;display:flex;gap:6px"><input id="savedname" placeholder="Name this view…" style="flex:1;min-width:0;padding:5px 8px"><button id="savedadd" class="loadmore" style="margin:0;padding:5px 11px">Save</button></div>`+
    (list.length ? list.map((v,i)=>`<div class="savedrow"><a class="savedapply" data-i="${i}" title="${escapeAttr(v.q)}">${escapeHtml(v.name)}</a><a class="saveddel" data-i="${i}" title="Delete">✕</a></div>`).join('')
      : `<div style="padding:6px 8px;color:var(--muted);font-size:12px">No saved views yet — filter, then Save.</div>`);
  const save=()=>{ const n=$('#savedname').value.trim(); if(!n) return; syncURL(); const q=location.search.replace(/^\?/,''); const arr=getSaved(); arr.push({name:n,q}); LS.set('saved',arr); renderSavedMenu(); };
  $('#savedadd').onclick=save;
  $('#savedname').onkeydown=e=>{ if(e.key==='Enter') save(); };
  pop.querySelectorAll('.savedapply').forEach(a=>a.onclick=()=>{ const v=getSaved()[+a.dataset.i]; if(v){ applySavedView(v.q); pop.classList.add('hidden'); } });
  pop.querySelectorAll('.saveddel').forEach(a=>a.onclick=()=>{ const arr=getSaved(); arr.splice(+a.dataset.i,1); LS.set('saved',arr); renderSavedMenu(); });
}
$('#savedbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#savedpop');
  if(pop.classList.contains('hidden')){ renderSavedMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
document.addEventListener('click',e=>{ if(!e.target.closest('#savedwrap')) $('#savedpop').classList.add('hidden'); });

(function(){
  hydrateFromQuery(location.search, false); // boot: deep-link params win, else persisted prefs
  syncControls();
  loadFacets(); loadRows(false);
  fetch(api('/api/facets')).then(r=>r.json()).then(d=>{ allFacets=d; renderMapJump(); }).catch(()=>{}); // stable full CD/ZIP lists for the map jump
})();
</script>
</body>
</html>