← back to La Socrata Ingester
Promote merged card+lead-score viewer to index.html; preserve table as table.html
d9ab84f1f23ad440efea5a3d83b3cc85554c0a8e · 2026-08-11 08:53:20 -0700 · Steve Abrams
- index.html: card viewer (facet rail, search/sort/density, detail modal) + permit_lead_score Rank badge on every card, 'Rank (high->low)' sort, 'Lead score N/100' in modal; header nav Cards/Table/Board
- table.html: previous sortable/movable-column table viewer, preserved + reachable from nav
- cards.html: removed (promoted into index.html)
Steve-directed (TK-10450). Verified in Chrome: / = cards w/ ranks, /table.html = table, /board.html = DTD board
Files touched
D viewer/public/cards.htmlM viewer/public/index.htmlA viewer/public/table.html
Diff
commit d9ab84f1f23ad440efea5a3d83b3cc85554c0a8e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 11 08:53:20 2026 -0700
Promote merged card+lead-score viewer to index.html; preserve table as table.html
- index.html: card viewer (facet rail, search/sort/density, detail modal) + permit_lead_score Rank badge on every card, 'Rank (high->low)' sort, 'Lead score N/100' in modal; header nav Cards/Table/Board
- table.html: previous sortable/movable-column table viewer, preserved + reachable from nav
- cards.html: removed (promoted into index.html)
Steve-directed (TK-10450). Verified in Chrome: / = cards w/ ranks, /table.html = table, /board.html = DTD board
---
viewer/public/cards.html | 494 -----------------------------------
viewer/public/index.html | 652 ++++++++++++++++++++++++++++++++---------------
viewer/public/table.html | 253 ++++++++++++++++++
3 files changed, 702 insertions(+), 697 deletions(-)
diff --git a/viewer/public/cards.html b/viewer/public/cards.html
deleted file mode 100644
index 0637fbe..0000000
--- a/viewer/public/cards.html
+++ /dev/null
@@ -1,494 +0,0 @@
-<!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>
-<style>
- :root{
- --bg:#f4f5f7; --panel:#ffffff; --ink:#1b1f24; --ink-soft:#5b636d; --ink-faint:#8a919b;
- --line:#e2e5ea; --line-strong:#d3d8df; --teal:#0d9488; --teal-ink:#0b7d72; --teal-bg:#e6f6f4;
- --amber:#b45309; --shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.04);
- --shadow-lg:0 12px 40px rgba(16,24,40,.22);
- --r:8px; --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
- --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
- }
- *{box-sizing:border-box}
- html,body{margin:0;height:100%}
- body{background:var(--bg);color:var(--ink);font:14px/1.45 var(--font);-webkit-font-smoothing:antialiased}
- button{font:inherit;cursor:pointer;color:inherit}
- input,select{font:inherit}
-
- /* ---------- layout ---------- */
- .app{display:grid;grid-template-columns:280px 1fr;grid-template-rows:auto 1fr;height:100vh}
- header.top{grid-column:1/3;background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;z-index:5}
- aside.rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:6px 0 40px}
- main.results{overflow-y:auto;padding:16px}
-
- /* ---------- top bar ---------- */
- .brand{display:flex;align-items:baseline;gap:8px;min-width:210px}
- .brand b{font-size:15px;letter-spacing:-.2px}
- .brand .tag{font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.6px}
- .search{position:relative;flex:1;min-width:220px;max-width:520px}
- .search input{width:100%;padding:9px 12px 9px 34px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;outline:none}
- .search input:focus{border-color:var(--teal);box-shadow:0 0 0 3px var(--teal-bg)}
- .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--ink-faint)}
- .count{font-size:13px;color:var(--ink-soft);white-space:nowrap}
- .count b{color:var(--ink);font-variant-numeric:tabular-nums}
- .ctrl{display:flex;align-items:center;gap:6px;color:var(--ink-soft);font-size:12px}
- .ctrl select{padding:7px 8px;border:1px solid var(--line-strong);border-radius:6px;background:#fff;outline:none}
- .ctrl select:focus{border-color:var(--teal)}
- .dens{display:flex;align-items:center;gap:8px}
- .dens input[type=range]{width:104px;accent-color:var(--teal)}
-
- /* ---------- active filter chips ---------- */
- .chips{grid-column:1/3;display:flex;gap:8px;flex-wrap:wrap;padding:8px 16px 0;background:var(--bg)}
- .chips:empty{display:none}
- .chip{display:inline-flex;align-items:center;gap:6px;background:var(--teal-bg);color:var(--teal-ink);border:1px solid #bfe8e2;border-radius:999px;padding:3px 6px 3px 10px;font-size:12px;font-weight:600}
- .chip button{background:none;border:none;color:var(--teal-ink);line-height:1;font-size:14px;padding:0 2px;opacity:.7}
- .chip button:hover{opacity:1}
- .chip .clr-all{color:var(--ink-soft);font-weight:600;text-decoration:underline;background:none;border:none;font-size:12px}
-
- /* ---------- rail facets ---------- */
- .rail-hd{padding:12px 16px 6px;font-size:11px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:var(--ink-faint)}
- .minirow{padding:0 12px 6px;display:flex;gap:8px}
- .minirow label{flex:1;display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.4px}
- .minirow input{padding:6px 8px;border:1px solid var(--line-strong);border-radius:6px;outline:none;width:100%}
- .minirow input:focus{border-color:var(--teal)}
- .facet{border-top:1px solid var(--line)}
- .facet:first-of-type{border-top:none}
- .facet>.fhd{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;background:none;border:none;text-align:left}
- .facet>.fhd:hover{background:#fafbfc}
- .fhd .ttl{font-weight:600;font-size:13px;display:flex;align-items:center;gap:7px}
- .fhd .caret{color:var(--ink-faint);transition:transform .15s;font-size:10px}
- .facet.collapsed .caret{transform:rotate(-90deg)}
- .facet.collapsed .fbody{display:none}
- .fhd .seldot{width:6px;height:6px;border-radius:50%;background:var(--teal);display:none}
- .facet.has-sel .seldot{display:inline-block}
- .fbody{max-height:260px;overflow-y:auto;padding:2px 8px 8px}
- .fv{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:5px 8px;border:none;background:none;border-radius:6px;text-align:left;color:var(--ink-soft)}
- .fv:hover{background:var(--teal-bg);color:var(--teal-ink)}
- .fv.active{background:var(--teal);color:#fff}
- .fv.active .fc{color:#fff;background:rgba(255,255,255,.22)}
- .fv .fname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
- .fv .fc{font-size:11px;color:var(--ink-faint);background:#eef0f3;border-radius:999px;padding:1px 7px;font-variant-numeric:tabular-nums;flex:none}
-
- /* ---------- cards ---------- */
- .grid{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:12px}
- .card{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:13px 14px;display:flex;flex-direction:column;gap:9px;box-shadow:var(--shadow);cursor:pointer;transition:border-color .12s,box-shadow .12s;text-align:left;width:100%}
- .card:hover{border-color:var(--teal);box-shadow:0 2px 8px rgba(13,148,136,.14)}
- .card .addr{font-weight:700;font-size:14.5px;letter-spacing:-.2px;line-height:1.25;padding-right:48px}
- .rank{position:absolute;top:11px;right:11px;min-width:34px;text-align:center;color:#fff;border-radius:6px;padding:3px 6px 2px;box-shadow:0 1px 2px rgba(0,0,0,.14);line-height:1}
- .rank b{font-size:14px;font-weight:800;display:block;font-variant-numeric:tabular-nums}
- .rank .lbl{font-size:7.5px;font-weight:700;letter-spacing:.6px;opacity:.92}
- .card .badges{display:flex;flex-wrap:wrap;gap:5px}
- .badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:5px;background:#eef1f4;color:#485058;white-space:nowrap}
- .badge.wt{background:#eaf1fb;color:#2b5aa0}
- .badge.bt{background:#f1eefc;color:#5b45a6}
- .val{font-size:19px;font-weight:700;letter-spacing:-.4px;font-variant-numeric:tabular-nums}
- .val.zero{color:var(--ink-faint);font-weight:600;font-size:14px}
- .metarow{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;color:var(--ink-soft)}
- .pill{font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;border:1px solid var(--line-strong);background:#fff;white-space:nowrap}
- .pill.ok{background:#eafaf1;color:#1a7f47;border-color:#bce9cf}
- .pill.warn{background:#fef6e7;color:#a16207;border-color:#f5e0a8}
- .pill.mut{background:#f3f4f6;color:#6b7280}
- .subrow{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink-soft);border-top:1px dashed var(--line);padding-top:8px}
- .subrow span b{color:var(--ink);font-weight:600}
- .subrow .parcel{color:var(--teal-ink)}
- .era-tag{font-size:10.5px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.5px}
-
- /* ---------- load more / states ---------- */
- .foot{display:flex;justify-content:center;padding:22px 0 8px}
- .loadmore{padding:10px 26px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;font-weight:600;color:var(--ink)}
- .loadmore:hover:not(:disabled){border-color:var(--teal);color:var(--teal-ink)}
- .loadmore:disabled{opacity:.5;cursor:default}
- .empty,.loading{text-align:center;color:var(--ink-faint);padding:60px 20px;font-size:14px}
- .skel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);height:150px;box-shadow:var(--shadow);position:relative;overflow:hidden}
- .skel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,0,0,.035),transparent);animation:sh 1.1s infinite}
- @keyframes sh{100%{transform:translateX(100%)}}
-
- /* ---------- modal ---------- */
- .scrim{position:fixed;inset:0;background:rgba(16,24,40,.34);display:none;z-index:40}
- .scrim.open{display:block}
- .modal{position:fixed;top:80px;left:50%;transform:translateX(-50%);width:min(680px,94vw);max-height:82vh;background:var(--panel);border:1px solid var(--line-strong);border-radius:12px;box-shadow:var(--shadow-lg);display:none;flex-direction:column;z-index:41;overflow:hidden}
- .modal.open{display:flex}
- .m-hd{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);cursor:move;user-select:none;background:linear-gradient(#fff,#fcfdfe)}
- .m-hd .h-txt{flex:1;min-width:0}
- .m-hd .h-addr{font-weight:700;font-size:16px;letter-spacing:-.2px}
- .m-hd .h-sub{font-size:12px;color:var(--ink-soft);margin-top:2px}
- .m-hd .x{background:none;border:none;font-size:22px;line-height:1;color:var(--ink-faint);padding:0 4px}
- .m-hd .x:hover{color:var(--ink)}
- .m-bd{padding:14px 16px;overflow-y:auto}
- .m-val{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:14px}
- .m-val .big{font-size:26px;font-weight:800;letter-spacing:-.6px;font-variant-numeric:tabular-nums}
- .kv{display:grid;grid-template-columns:150px 1fr;gap:2px 14px;font-size:13px}
- .kv dt{color:var(--ink-faint);padding:6px 0;border-top:1px solid var(--line)}
- .kv dd{margin:0;padding:6px 0;border-top:1px solid var(--line);color:var(--ink);word-break:break-word}
- .kv dd a{color:var(--teal-ink);text-decoration:none;font-weight:600}
- .kv dd a:hover{text-decoration:underline}
- .kv .flt{cursor:pointer;color:var(--teal-ink);text-decoration:underline dotted;font-weight:600}
- .sect{margin-top:16px;font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-faint)}
- .desc{margin-top:8px;font-size:13px;color:var(--ink-soft);background:#f8f9fb;border:1px solid var(--line);border-radius:8px;padding:10px 12px}
- .m-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}
- .m-btn{padding:8px 14px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font-weight:600;font-size:13px;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:6px}
- .m-btn:hover{border-color:var(--teal);color:var(--teal-ink)}
- .m-btn.primary{background:var(--teal);border-color:var(--teal);color:#fff}
- .m-btn.primary:hover{background:var(--teal-ink);color:#fff}
- pre.raw{display:none;margin-top:10px;background:#0f172a;color:#cbd5e1;border-radius:8px;padding:12px;font:12px/1.5 var(--mono);overflow:auto;max-height:320px}
- pre.raw.open{display:block}
- .rz{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;color:var(--ink-faint)}
-
- @media(max-width:820px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}
- aside.rail{max-height:230px;border-right:none;border-bottom:1px solid var(--line)}}
-</style>
-</head>
-<body>
-<div class="app">
- <header class="top">
- <div class="brand"><b>LA Building Permits</b><span class="tag">City of Los Angeles</span></div>
- <div class="search">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
- <input id="q" type="search" placeholder="Search by address…" autocomplete="off" spellcheck="false">
- </div>
- <div class="count" id="count">Loading…</div>
- <div class="ctrl">Sort
- <select id="sort">
- <option value="lead_score">Rank (high → low)</option>
- <option value="newest">Newest</option>
- <option value="value_desc">Value high → low</option>
- <option value="value_asc">Value low → high</option>
- <option value="oldest">Oldest</option>
- <option value="address">Address A→Z</option>
- </select>
- </div>
- <div class="ctrl dens">Density
- <input id="dens" type="range" min="1" max="6" step="1" value="3">
- </div>
- </header>
- <div class="chips" id="chips"></div>
-
- <aside class="rail" id="rail">
- <div class="rail-hd">Filters</div>
- <div class="minirow">
- <label>Min value $<input id="minval" type="number" min="0" step="1000" placeholder="any" inputmode="numeric"></label>
- <label>Issued since<input id="since" type="date"></label>
- </div>
- <div id="facets"></div>
- </aside>
-
- <main class="results">
- <div id="grid" class="grid"></div>
- <div class="foot"><button id="loadmore" class="loadmore" style="display:none">Load more</button></div>
- </main>
-</div>
-
-<!-- detail modal -->
-<div class="scrim" id="scrim"></div>
-<div class="modal" id="modal" role="dialog" aria-modal="true">
- <div class="m-hd" id="mHd">
- <div class="h-txt"><div class="h-addr" id="mAddr">—</div><div class="h-sub" id="mSub"></div></div>
- <button class="x" id="mClose" aria-label="Close">×</button>
- </div>
- <div class="m-bd" id="mBody"></div>
- <svg class="rz" id="mRz" viewBox="0 0 16 16" fill="currentColor"><path d="M16 16H12L16 12V16ZM16 9H9L16 2V9ZM16 5H13L16 2V5Z" opacity=".6"/><circle cx="14" cy="14" r="1.3"/><circle cx="14" cy="10" r="1.3"/><circle cx="10" cy="14" r="1.3"/></svg>
-</div>
-
-<script>
-const API = location.origin;
-const AUTH = {credentials:'include'};
-const $ = s=>document.querySelector(s);
-const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;};
-
-// ---- filter definitions ----
-const FACETS = [
- {key:'era', label:'Era'},
- {key:'work_type', label:'Work Type'},
- {key:'building_type', label:'Building Type'},
- {key:'status', label:'Status'},
- {key:'cd', label:'Council District'},
- {key:'zip', label:'ZIP'},
-];
-const ERA_ORDER = {'2020-present':0,'2010-2019':1,'pre-2010':2};
-
-// ---- persisted UI prefs ----
-const LS = {
- get sort(){return localStorage.getItem('lap.sort')||'newest';},
- set sort(v){localStorage.setItem('lap.sort',v);},
- get dens(){return localStorage.getItem('lap.dens')||'3';},
- set dens(v){localStorage.setItem('lap.dens',v);},
- get collapsed(){try{return JSON.parse(localStorage.getItem('lap.collapsed'))||{};}catch(e){return{};}},
- set collapsed(o){localStorage.setItem('lap.collapsed',JSON.stringify(o));},
-};
-
-// ---- state ----
-const state = {q:'',era:'',work_type:'',building_type:'',status:'',cd:'',zip:'',min_value:'',since:'',
- sort:LS.sort, page:1, limit:60, total:0, loaded:0, busy:false};
-const FILTER_KEYS = ['q','era','work_type','building_type','status','cd','zip','min_value','since'];
-
-// ---- formatting ----
-const nfmt = new Intl.NumberFormat('en-US');
-const money = v=>{const n=Number(v); if(!n||n<=0) return null; return '$'+nfmt.format(Math.round(n));};
-const fmtDate = v=>{ if(!v) return '—'; const d=new Date(v); if(isNaN(d)) return '—';
- return d.toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); };
-const statusClass = s=>{ const t=(s||'').toLowerCase();
- if(/final|issued|cofo|complete|approved/.test(t)) return 'ok';
- if(/expired|revoked|denied|withdrawn|cancel|void|abandon/.test(t)) return 'warn';
- return 'mut'; };
-// Lead-score (0–100, db/lead_score.sql · permit_lead_score) → colour tier.
-const scoreColor = s=>{ s=Number(s); return s>=75?'#0f766e':s>=55?'#0d9488':s>=35?'#d97706':'#94a3b8'; };
-const rankBadge = s=>{ if(s==null||s==='') return ''; const n=Number(s);
- return `<span class="rank" style="background:${scoreColor(n)}" title="Lead score ${n}/100"><b>${n}</b><span class="lbl">RANK</span></span>`; };
-const esc = s=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
-
-function qs(extra, omit){
- const p=new URLSearchParams();
- FILTER_KEYS.forEach(k=>{ if(k!==omit && state[k]!=='' && state[k]!=null) p.set(k,state[k]); });
- if(extra) Object.entries(extra).forEach(([k,v])=>p.set(k,v));
- return p.toString();
-}
-
-// ---- data loads ----
-// Sequence guards: a slower earlier request must never overwrite a newer render.
-let facetSeq=0, permitSeq=0;
-async function loadFacets(){
- const my=++facetSeq;
- try{
- const r = await fetch(`${API}/api/facets?${qs()}`,AUTH);
- const data = await r.json();
- // A facet with an active selection would otherwise collapse to just that value
- // (server applies every filter to every facet). Refetch each active dimension
- // WITHOUT its own filter so its alternative values stay switchable.
- const activeFacetKeys = FACETS.map(f=>f.key).filter(k=>state[k]!=='' && state[k]!=null);
- await Promise.all(activeFacetKeys.map(async k=>{
- try{ const alt = await (await fetch(`${API}/api/facets?${qs(null,k)}`,AUTH)).json(); data[k]=alt[k]; }catch(e){}
- }));
- if(my!==facetSeq) return; // superseded by a newer filter change
- renderFacets(data);
- }catch(e){ /* keep prior facets on error */ }
-}
-async function loadPermits(reset){
- if(state.busy && !reset) return;
- const my = reset ? ++permitSeq : permitSeq;
- state.busy=true;
- if(reset){ state.page=1; state.loaded=0; renderSkeleton(); }
- else { $('#loadmore').disabled=true; $('#loadmore').textContent='Loading…'; }
- try{
- const r = await fetch(`${API}/api/permits?${qs({sort:state.sort,page:state.page,limit:state.limit})}`,AUTH);
- const data = await r.json();
- if(reset && my!==permitSeq) return; // a newer reset superseded this one
- state.total = data.total; state.loaded += data.rows.length;
- renderCards(data.rows, reset);
- updateCount();
- const more = state.loaded < state.total;
- const lm=$('#loadmore');
- lm.style.display = more ? '' : 'none';
- lm.disabled=false; lm.textContent = `Load more (${nfmt.format(state.total-state.loaded)} more)`;
- }catch(e){
- if(reset) $('#grid').innerHTML = `<div class="empty">Failed to load permits — ${esc(e.message)}</div>`;
- }finally{ state.busy=false; }
-}
-function reloadAll(){ loadPermits(true); loadFacets(); syncChips(); }
-
-// ---- render: facets ----
-function renderFacets(data){
- const host=$('#facets'); const collapsed=LS.collapsed;
- host.innerHTML='';
- FACETS.forEach(({key,label})=>{
- let vals=(data[key]||[]).slice();
- if(key==='era') vals.sort((a,b)=>(ERA_ORDER[a.v]??9)-(ERA_ORDER[b.v]??9));
- if(key==='cd') vals.sort((a,b)=>(+a.v||99)-(+b.v||99));
- const sel = state[key];
- const sec = el('div','facet'+(collapsed[key]?' collapsed':'')+(sel?' has-sel':''));
- const hd = el('button','fhd',
- `<span class="ttl"><span class="seldot"></span>${label}</span><span class="caret">▼</span>`);
- hd.onclick=()=>{ const c=LS.collapsed; c[key]=!sec.classList.contains('collapsed'); LS.collapsed=c; sec.classList.toggle('collapsed'); };
- const body=el('div','fbody');
- vals.forEach(({v,c})=>{
- const active = String(sel)===String(v);
- const b=el('button','fv'+(active?' active':''),
- `<span class="fname">${esc(v)}</span><span class="fc">${nfmt.format(c)}</span>`);
- b.title=`${v} · ${nfmt.format(c)}`;
- b.onclick=()=>{ setFilter(key, active?'':v); };
- body.appendChild(b);
- });
- sec.appendChild(hd); sec.appendChild(body); host.appendChild(sec);
- });
-}
-
-function setFilter(key,val){
- state[key]=val; state.page=1;
- reloadAll();
-}
-
-// ---- render: cards ----
-function renderSkeleton(){ const g=$('#grid'); g.innerHTML=''; for(let i=0;i<9;i++) g.appendChild(el('div','skel')); }
-function renderCards(rows, reset){
- const g=$('#grid');
- if(reset){ g.innerHTML=''; if(!rows.length){ g.innerHTML='<div class="empty">No permits match these filters.</div>'; return; } }
- rows.forEach(r=>g.appendChild(cardEl(r)));
-}
-function cardEl(r){
- const c=el('button','card'); c.type='button';
- const m=money(r.project_value);
- const enrich=[];
- if(r.assessed_value) enrich.push(`<span>Assessed <b>${money(r.assessed_value)||'—'}</b></span>`);
- if(r.year_built && Number(r.year_built)>0) enrich.push(`<span>Built <b>${esc(r.year_built)}</b></span>`);
- if(r.sqft && Number(r.sqft)>0) enrich.push(`<span><b>${nfmt.format(Number(r.sqft))}</b> sqft</span>`);
- c.innerHTML=`
- ${rankBadge(r.lead_score)}
- <div class="addr">${esc(r.address||'—')}</div>
- <div class="badges">
- ${r.work_type?`<span class="badge wt">${esc(r.work_type)}</span>`:''}
- ${r.building_type?`<span class="badge bt">${esc(r.building_type)}</span>`:''}
- </div>
- ${m?`<div class="val">${m}</div>`:`<div class="val zero">No stated value</div>`}
- <div class="metarow">
- <span>${fmtDate(r.issue_date)}</span>
- <span class="pill ${statusClass(r.status)}">${esc(r.status||'—')}</span>
- </div>
- <div class="metarow">
- <span class="era-tag">${esc(r.era||'')}</span>
- <span>${r.cd?('CD '+esc(r.cd)):''}${r.zip?(' · '+esc(r.zip)):''}</span>
- </div>
- ${enrich.length?`<div class="subrow parcel">${enrich.join('')}</div>`:''}`;
- c.onclick=()=>openDetail(r.permit_nbr, r);
- return c;
-}
-function updateCount(){
- $('#count').innerHTML = `<b>${nfmt.format(state.total)}</b> permit${state.total===1?'':'s'} · showing <b>${nfmt.format(state.loaded)}</b>`;
-}
-
-// ---- active chips ----
-function labelFor(k){return ({q:'Search',era:'Era',work_type:'Work',building_type:'Building',status:'Status',cd:'CD',zip:'ZIP',min_value:'Min $',since:'Since'})[k]||k;}
-function syncChips(){
- const host=$('#chips'); host.innerHTML='';
- const active = FILTER_KEYS.filter(k=>state[k]!=='' && state[k]!=null);
- if(!active.length) return;
- active.forEach(k=>{
- let disp=state[k];
- if(k==='min_value') disp='≥ $'+nfmt.format(state[k]);
- if(k==='since') disp='since '+fmtDate(state[k]);
- const chip=el('span','chip',`${labelFor(k)}: ${esc(disp)}<button title="Remove">×</button>`);
- chip.querySelector('button').onclick=()=>clearFilter(k);
- host.appendChild(chip);
- });
- const all=el('button','clr-all',null,'Clear all'); all.textContent='Clear all';
- all.onclick=clearAll; host.appendChild(all);
-}
-function clearFilter(k){
- state[k]='';
- if(k==='q') $('#q').value='';
- if(k==='min_value') $('#minval').value='';
- if(k==='since') $('#since').value='';
- reloadAll();
-}
-function clearAll(){
- FILTER_KEYS.forEach(k=>state[k]='');
- $('#q').value=''; $('#minval').value=''; $('#since').value='';
- reloadAll();
-}
-
-// ---- detail modal ----
-const modal=$('#modal'), scrim=$('#scrim');
-let detailScore=null; // lead_score carried from the card row (detail endpoint omits it)
-async function openDetail(nbr, fallback){
- detailScore = (fallback && fallback.lead_score!=null) ? fallback.lead_score : null;
- modal.classList.add('open'); scrim.classList.add('open');
- $('#mAddr').textContent = fallback?.address || nbr;
- $('#mSub').textContent = 'Loading permit '+nbr+'…';
- $('#mBody').innerHTML = '<div class="loading">Loading detail…</div>';
- applyModalRig();
- try{
- const r=await fetch(`${API}/api/permit/${encodeURIComponent(nbr)}`,AUTH);
- if(!r.ok) throw new Error('HTTP '+r.status);
- renderDetail(await r.json());
- }catch(e){
- $('#mBody').innerHTML=`<div class="empty">Couldn't load detail — ${esc(e.message)}</div>`;
- }
-}
-function closeDetail(){ modal.classList.remove('open'); scrim.classList.remove('open'); }
-scrim.onclick=closeDetail; $('#mClose').onclick=closeDetail;
-document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDetail(); });
-
-function kvFilter(k,v,text){ // clickable value that applies a filter and closes modal
- return `<span class="flt" data-fk="${k}" data-fv="${esc(v)}">${esc(text!=null?text:v)}</span>`;
-}
-function renderDetail(d){
- $('#mAddr').textContent = d.primary_address || d.permit_nbr;
- $('#mSub').innerHTML = `${esc(d.permit_nbr)} · ${esc(d.era||'')}`;
- const val=money(d.valuation);
- const gmap = (d.lat&&d.lon)?`https://www.google.com/maps/search/?api=1&query=${d.lat},${d.lon}`:null;
- const rows=[
- ['Status', `<span class="pill ${statusClass(d.status_desc)}">${esc(d.status_desc||'—')}</span>`],
- ['Issue date', fmtDate(d.issue_date)],
- ['Work type', d.permit_type?kvFilter('work_type',d.permit_type):'—'],
- ['Building type', d.permit_sub_type?kvFilter('building_type',d.permit_sub_type):'—'],
- ['Permit group', esc(d.permit_group||'—')],
- ['Use', esc(d.use_desc||'—')],
- ['Council district', d.council_district?kvFilter('cd',d.council_district,'CD '+d.council_district):'—'],
- ['ZIP', d.zip_code?kvFilter('zip',d.zip_code):'—'],
- ['APN / parcel', esc(d.apn||'—')],
- ];
- const parcel=[];
- if(d.parcel_assessed) parcel.push(['Assessed value', money(d.parcel_assessed)||'—']);
- if(d.parcel_year_built && Number(d.parcel_year_built)>0) parcel.push(['Year built', esc(d.parcel_year_built)]);
- if(d.parcel_sqft && Number(d.parcel_sqft)>0) parcel.push(['Living area', nfmt.format(Number(d.parcel_sqft))+' sqft']);
- if(d.parcel_use) parcel.push(['Parcel use', esc(d.parcel_use)]);
- if(d.parcel_address) parcel.push(['Parcel address', esc(d.parcel_address)]);
-
- const scoreChip = detailScore!=null
- ? `<span class="pill" style="background:${scoreColor(detailScore)};color:#fff;border-color:transparent;font-weight:700">Lead score ${Number(detailScore)}/100</span>` : '';
- let html = `
- <div class="m-val">${val?`<span class="big">${val}</span><span style="color:var(--ink-faint)">project valuation</span>`:`<span class="big" style="font-size:18px;color:var(--ink-faint)">No stated valuation</span>`}${scoreChip}</div>
- <dl class="kv">${rows.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`;
- if(d.work_desc){ html+=`<div class="sect">Scope of work</div><div class="desc">${esc(d.work_desc)}</div>`; }
- if(parcel.length){ html+=`<div class="sect">Parcel enrichment (Assessor 2025)</div><dl class="kv">${parcel.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`; }
- html+=`<div class="m-actions">
- ${gmap?`<a class="m-btn primary" href="${gmap}" target="_blank" rel="noopener noreferrer">📍 Open in Google Maps</a>`:''}
- ${d.source_url?`<a class="m-btn" href="${esc(d.source_url)}" target="_blank" rel="noopener noreferrer">Source record ↗</a>`:''}
- <button class="m-btn" id="rawToggle">{ } Raw JSON</button>
- </div>
- <pre class="raw" id="rawBox">${esc(JSON.stringify(d.raw||d,null,2))}</pre>`;
- const body=$('#mBody'); body.innerHTML=html;
- body.querySelector('#rawToggle').onclick=e=>{ $('#rawBox').classList.toggle('open'); e.currentTarget.blur(); };
- body.querySelectorAll('.flt').forEach(s=>s.onclick=()=>{ setFilter(s.dataset.fk, s.dataset.fv); closeDetail(); });
-}
-
-// ---- modal rig: drag + resize + persist ----
-function applyModalRig(){
- try{ const s=JSON.parse(localStorage.getItem('lap.modal'));
- if(s){ modal.style.left=s.left; modal.style.top=s.top; modal.style.transform='none';
- if(s.w) modal.style.width=s.w; if(s.h) modal.style.height=s.h; } }catch(e){}
-}
-(function rig(){
- const hd=$('#mHd'), rz=$('#mRz'); let d=null;
- const save=()=>localStorage.setItem('lap.modal',JSON.stringify({
- left:modal.style.left,top:modal.style.top,w:modal.style.width,h:modal.style.height}));
- hd.addEventListener('mousedown',e=>{ if(e.target.closest('.x'))return;
- const r=modal.getBoundingClientRect(); modal.style.transform='none'; modal.style.left=r.left+'px'; modal.style.top=r.top+'px';
- d={type:'move',x:e.clientX,y:e.clientY,l:r.left,t:r.top}; e.preventDefault(); });
- rz.addEventListener('mousedown',e=>{ const r=modal.getBoundingClientRect();
- d={type:'size',x:e.clientX,y:e.clientY,w:r.width,h:r.height}; e.preventDefault(); e.stopPropagation(); });
- window.addEventListener('mousemove',e=>{ if(!d)return;
- if(d.type==='move'){ modal.style.left=(d.l+e.clientX-d.x)+'px'; modal.style.top=Math.max(0,d.t+e.clientY-d.y)+'px'; }
- else { modal.style.width=Math.max(340,d.w+e.clientX-d.x)+'px'; modal.style.height=Math.max(240,d.h+e.clientY-d.y)+'px'; } });
- window.addEventListener('mouseup',()=>{ if(d){ save(); d=null; } });
-})();
-
-// ---- top-bar wiring ----
-let qTimer;
-$('#q').addEventListener('input',e=>{ clearTimeout(qTimer); qTimer=setTimeout(()=>{ state.q=e.target.value.trim(); reloadAll(); },300); });
-$('#sort').addEventListener('change',e=>{ state.sort=e.target.value; LS.sort=e.target.value; loadPermits(true); });
-$('#dens').addEventListener('input',e=>{ document.documentElement.style.setProperty('--cols',e.target.value); LS.dens=e.target.value; });
-let mvTimer;
-$('#minval').addEventListener('input',e=>{ clearTimeout(mvTimer); mvTimer=setTimeout(()=>{ state.min_value=e.target.value?String(e.target.value):''; reloadAll(); },350); });
-$('#since').addEventListener('change',e=>{ state.since=e.target.value||''; reloadAll(); });
-$('#loadmore').addEventListener('click',()=>{ state.page++; loadPermits(false); });
-
-// ---- boot ----
-$('#sort').value=state.sort;
-$('#dens').value=LS.dens; document.documentElement.style.setProperty('--cols',LS.dens);
-reloadAll();
-</script>
-</body>
-</html>
diff --git a/viewer/public/index.html b/viewer/public/index.html
index 061dcce..1d6fb12 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -6,248 +6,494 @@
<title>LA Building Permits</title>
<style>
:root{
- --bg:#f6f7f9; --panel:#fff; --ink:#1a1f24; --muted:#6b7480; --line:#e3e7ec;
- --teal:#0f766e; --teal-soft:#d7efec; --accent:#0e7490; --rowh:34px; --pad:6px 10px;
+ --bg:#f4f5f7; --panel:#ffffff; --ink:#1b1f24; --ink-soft:#5b636d; --ink-faint:#8a919b;
+ --line:#e2e5ea; --line-strong:#d3d8df; --teal:#0d9488; --teal-ink:#0b7d72; --teal-bg:#e6f6f4;
+ --amber:#b45309; --shadow:0 1px 2px rgba(16,24,40,.06),0 1px 3px rgba(16,24,40,.04);
+ --shadow-lg:0 12px 40px rgba(16,24,40,.22);
+ --r:8px; --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{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;height:100vh}
- header{grid-column:1/3;display:flex;align-items:center;gap:14px;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:230px}
- .ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
- 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:230px;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}
- 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}
- 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}
- .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}
- .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-bg{position:fixed;inset:0;background:rgba(20,25,30,.35);display:none;z-index:20}
- .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:21;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}
+ html,body{margin:0;height:100%}
+ body{background:var(--bg);color:var(--ink);font:14px/1.45 var(--font);-webkit-font-smoothing:antialiased}
+ button{font:inherit;cursor:pointer;color:inherit}
+ input,select{font:inherit}
+
+ /* ---------- layout ---------- */
+ .app{display:grid;grid-template-columns:280px 1fr;grid-template-rows:auto 1fr;height:100vh}
+ header.top{grid-column:1/3;background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;z-index:5}
+ aside.rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:6px 0 40px}
+ main.results{overflow-y:auto;padding:16px}
+
+ /* ---------- top bar ---------- */
+ .brand{display:flex;align-items:baseline;gap:8px;min-width:210px}
+ .brand b{font-size:15px;letter-spacing:-.2px}
+ .brand .tag{font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.6px}
+ .viewnav{display:flex;gap:3px}
+ .viewnav a{font-size:12px;color:var(--ink-soft);text-decoration:none;padding:5px 10px;border-radius:6px;border:1px solid transparent;font-weight:600}
+ .viewnav a:hover{background:var(--teal-bg);color:var(--teal-ink)}
+ .viewnav a.on{background:var(--teal);color:#fff}
+ .search{position:relative;flex:1;min-width:220px;max-width:520px}
+ .search input{width:100%;padding:9px 12px 9px 34px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;outline:none}
+ .search input:focus{border-color:var(--teal);box-shadow:0 0 0 3px var(--teal-bg)}
+ .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--ink-faint)}
+ .count{font-size:13px;color:var(--ink-soft);white-space:nowrap}
+ .count b{color:var(--ink);font-variant-numeric:tabular-nums}
+ .ctrl{display:flex;align-items:center;gap:6px;color:var(--ink-soft);font-size:12px}
+ .ctrl select{padding:7px 8px;border:1px solid var(--line-strong);border-radius:6px;background:#fff;outline:none}
+ .ctrl select:focus{border-color:var(--teal)}
+ .dens{display:flex;align-items:center;gap:8px}
+ .dens input[type=range]{width:104px;accent-color:var(--teal)}
+
+ /* ---------- active filter chips ---------- */
+ .chips{grid-column:1/3;display:flex;gap:8px;flex-wrap:wrap;padding:8px 16px 0;background:var(--bg)}
+ .chips:empty{display:none}
+ .chip{display:inline-flex;align-items:center;gap:6px;background:var(--teal-bg);color:var(--teal-ink);border:1px solid #bfe8e2;border-radius:999px;padding:3px 6px 3px 10px;font-size:12px;font-weight:600}
+ .chip button{background:none;border:none;color:var(--teal-ink);line-height:1;font-size:14px;padding:0 2px;opacity:.7}
+ .chip button:hover{opacity:1}
+ .chip .clr-all{color:var(--ink-soft);font-weight:600;text-decoration:underline;background:none;border:none;font-size:12px}
+
+ /* ---------- rail facets ---------- */
+ .rail-hd{padding:12px 16px 6px;font-size:11px;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:var(--ink-faint)}
+ .minirow{padding:0 12px 6px;display:flex;gap:8px}
+ .minirow label{flex:1;display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.4px}
+ .minirow input{padding:6px 8px;border:1px solid var(--line-strong);border-radius:6px;outline:none;width:100%}
+ .minirow input:focus{border-color:var(--teal)}
+ .facet{border-top:1px solid var(--line)}
+ .facet:first-of-type{border-top:none}
+ .facet>.fhd{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;background:none;border:none;text-align:left}
+ .facet>.fhd:hover{background:#fafbfc}
+ .fhd .ttl{font-weight:600;font-size:13px;display:flex;align-items:center;gap:7px}
+ .fhd .caret{color:var(--ink-faint);transition:transform .15s;font-size:10px}
+ .facet.collapsed .caret{transform:rotate(-90deg)}
+ .facet.collapsed .fbody{display:none}
+ .fhd .seldot{width:6px;height:6px;border-radius:50%;background:var(--teal);display:none}
+ .facet.has-sel .seldot{display:inline-block}
+ .fbody{max-height:260px;overflow-y:auto;padding:2px 8px 8px}
+ .fv{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:5px 8px;border:none;background:none;border-radius:6px;text-align:left;color:var(--ink-soft)}
+ .fv:hover{background:var(--teal-bg);color:var(--teal-ink)}
+ .fv.active{background:var(--teal);color:#fff}
+ .fv.active .fc{color:#fff;background:rgba(255,255,255,.22)}
+ .fv .fname{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
+ .fv .fc{font-size:11px;color:var(--ink-faint);background:#eef0f3;border-radius:999px;padding:1px 7px;font-variant-numeric:tabular-nums;flex:none}
+
+ /* ---------- cards ---------- */
+ .grid{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:12px}
+ .card{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:13px 14px;display:flex;flex-direction:column;gap:9px;box-shadow:var(--shadow);cursor:pointer;transition:border-color .12s,box-shadow .12s;text-align:left;width:100%}
+ .card:hover{border-color:var(--teal);box-shadow:0 2px 8px rgba(13,148,136,.14)}
+ .card .addr{font-weight:700;font-size:14.5px;letter-spacing:-.2px;line-height:1.25;padding-right:48px}
+ .rank{position:absolute;top:11px;right:11px;min-width:34px;text-align:center;color:#fff;border-radius:6px;padding:3px 6px 2px;box-shadow:0 1px 2px rgba(0,0,0,.14);line-height:1}
+ .rank b{font-size:14px;font-weight:800;display:block;font-variant-numeric:tabular-nums}
+ .rank .lbl{font-size:7.5px;font-weight:700;letter-spacing:.6px;opacity:.92}
+ .card .badges{display:flex;flex-wrap:wrap;gap:5px}
+ .badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:5px;background:#eef1f4;color:#485058;white-space:nowrap}
+ .badge.wt{background:#eaf1fb;color:#2b5aa0}
+ .badge.bt{background:#f1eefc;color:#5b45a6}
+ .val{font-size:19px;font-weight:700;letter-spacing:-.4px;font-variant-numeric:tabular-nums}
+ .val.zero{color:var(--ink-faint);font-weight:600;font-size:14px}
+ .metarow{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;color:var(--ink-soft)}
+ .pill{font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;border:1px solid var(--line-strong);background:#fff;white-space:nowrap}
+ .pill.ok{background:#eafaf1;color:#1a7f47;border-color:#bce9cf}
+ .pill.warn{background:#fef6e7;color:#a16207;border-color:#f5e0a8}
+ .pill.mut{background:#f3f4f6;color:#6b7280}
+ .subrow{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink-soft);border-top:1px dashed var(--line);padding-top:8px}
+ .subrow span b{color:var(--ink);font-weight:600}
+ .subrow .parcel{color:var(--teal-ink)}
+ .era-tag{font-size:10.5px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:.5px}
+
+ /* ---------- load more / states ---------- */
+ .foot{display:flex;justify-content:center;padding:22px 0 8px}
+ .loadmore{padding:10px 26px;border:1px solid var(--line-strong);border-radius:var(--r);background:#fff;font-weight:600;color:var(--ink)}
+ .loadmore:hover:not(:disabled){border-color:var(--teal);color:var(--teal-ink)}
+ .loadmore:disabled{opacity:.5;cursor:default}
+ .empty,.loading{text-align:center;color:var(--ink-faint);padding:60px 20px;font-size:14px}
+ .skel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);height:150px;box-shadow:var(--shadow);position:relative;overflow:hidden}
+ .skel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,0,0,.035),transparent);animation:sh 1.1s infinite}
+ @keyframes sh{100%{transform:translateX(100%)}}
+
+ /* ---------- modal ---------- */
+ .scrim{position:fixed;inset:0;background:rgba(16,24,40,.34);display:none;z-index:40}
+ .scrim.open{display:block}
+ .modal{position:fixed;top:80px;left:50%;transform:translateX(-50%);width:min(680px,94vw);max-height:82vh;background:var(--panel);border:1px solid var(--line-strong);border-radius:12px;box-shadow:var(--shadow-lg);display:none;flex-direction:column;z-index:41;overflow:hidden}
+ .modal.open{display:flex}
+ .m-hd{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);cursor:move;user-select:none;background:linear-gradient(#fff,#fcfdfe)}
+ .m-hd .h-txt{flex:1;min-width:0}
+ .m-hd .h-addr{font-weight:700;font-size:16px;letter-spacing:-.2px}
+ .m-hd .h-sub{font-size:12px;color:var(--ink-soft);margin-top:2px}
+ .m-hd .x{background:none;border:none;font-size:22px;line-height:1;color:var(--ink-faint);padding:0 4px}
+ .m-hd .x:hover{color:var(--ink)}
+ .m-bd{padding:14px 16px;overflow-y:auto}
+ .m-val{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:14px}
+ .m-val .big{font-size:26px;font-weight:800;letter-spacing:-.6px;font-variant-numeric:tabular-nums}
+ .kv{display:grid;grid-template-columns:150px 1fr;gap:2px 14px;font-size:13px}
+ .kv dt{color:var(--ink-faint);padding:6px 0;border-top:1px solid var(--line)}
+ .kv dd{margin:0;padding:6px 0;border-top:1px solid var(--line);color:var(--ink);word-break:break-word}
+ .kv dd a{color:var(--teal-ink);text-decoration:none;font-weight:600}
+ .kv dd a:hover{text-decoration:underline}
+ .kv .flt{cursor:pointer;color:var(--teal-ink);text-decoration:underline dotted;font-weight:600}
+ .sect{margin-top:16px;font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--ink-faint)}
+ .desc{margin-top:8px;font-size:13px;color:var(--ink-soft);background:#f8f9fb;border:1px solid var(--line);border-radius:8px;padding:10px 12px}
+ .m-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:16px}
+ .m-btn{padding:8px 14px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font-weight:600;font-size:13px;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:6px}
+ .m-btn:hover{border-color:var(--teal);color:var(--teal-ink)}
+ .m-btn.primary{background:var(--teal);border-color:var(--teal);color:#fff}
+ .m-btn.primary:hover{background:var(--teal-ink);color:#fff}
+ pre.raw{display:none;margin-top:10px;background:#0f172a;color:#cbd5e1;border-radius:8px;padding:12px;font:12px/1.5 var(--mono);overflow:auto;max-height:320px}
+ pre.raw.open{display:block}
+ .rz{position:absolute;right:2px;bottom:2px;width:16px;height:16px;cursor:nwse-resize;color:var(--ink-faint)}
+
+ @media(max-width:820px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}
+ aside.rail{max-height:230px;border-right:none;border-bottom:1px solid var(--line)}}
</style>
</head>
<body>
<div class="app">
- <header>
- <h1>LA <b>Building Permits</b></h1>
- <input id="q" type="search" placeholder="Search address…" autocomplete="off">
- <span class="count" id="count">…</span>
- <span class="grow"></span>
- <span class="ctl">Row density <input id="density" type="range" min="26" max="46" value="34"></span>
- <span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
+ <header class="top">
+ <div class="brand"><b>LA Building Permits</b><span class="tag">City of Los Angeles</span></div>
+ <nav class="viewnav"><a href="/" class="on">Cards</a><a href="/table.html">Table</a><a href="/board.html">Board</a></nav>
+ <div class="search">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
+ <input id="q" type="search" placeholder="Search by address…" autocomplete="off" spellcheck="false">
+ </div>
+ <div class="count" id="count">Loading…</div>
+ <div class="ctrl">Sort
+ <select id="sort">
+ <option value="lead_score">Rank (high → low)</option>
+ <option value="newest">Newest</option>
+ <option value="value_desc">Value high → low</option>
+ <option value="value_asc">Value low → high</option>
+ <option value="oldest">Oldest</option>
+ <option value="address">Address A→Z</option>
+ </select>
+ </div>
+ <div class="ctrl dens">Density
+ <input id="dens" type="range" min="1" max="6" step="1" value="3">
+ </div>
</header>
- <aside id="rail"></aside>
- <main>
- <table id="tbl">
- <thead><tr id="head"></tr></thead>
- <tbody id="rows"></tbody>
- </table>
- <button class="loadmore" id="more" style="display:none">Load more</button>
+ <div class="chips" id="chips"></div>
+
+ <aside class="rail" id="rail">
+ <div class="rail-hd">Filters</div>
+ <div class="minirow">
+ <label>Min value $<input id="minval" type="number" min="0" step="1000" placeholder="any" inputmode="numeric"></label>
+ <label>Issued since<input id="since" type="date"></label>
+ </div>
+ <div id="facets"></div>
+ </aside>
+
+ <main class="results">
+ <div id="grid" class="grid"></div>
+ <div class="foot"><button id="loadmore" class="loadmore" style="display:none">Load more</button></div>
</main>
</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>
+<!-- detail modal -->
+<div class="scrim" id="scrim"></div>
+<div class="modal" id="modal" role="dialog" aria-modal="true">
+ <div class="m-hd" id="mHd">
+ <div class="h-txt"><div class="h-addr" id="mAddr">—</div><div class="h-sub" id="mSub"></div></div>
+ <button class="x" id="mClose" aria-label="Close">×</button>
+ </div>
+ <div class="m-bd" id="mBody"></div>
+ <svg class="rz" id="mRz" viewBox="0 0 16 16" fill="currentColor"><path d="M16 16H12L16 12V16ZM16 9H9L16 2V9ZM16 5H13L16 2V5Z" opacity=".6"/><circle cx="14" cy="14" r="1.3"/><circle cx="14" cy="10" r="1.3"/><circle cx="10" cy="14" r="1.3"/></svg>
</div>
<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)) };
-
-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'},
+const API = location.origin;
+const AUTH = {credentials:'include'};
+const $ = s=>document.querySelector(s);
+const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;};
+
+// ---- filter definitions ----
+const FACETS = [
+ {key:'era', label:'Era'},
+ {key:'work_type', label:'Work Type'},
+ {key:'building_type', label:'Building Type'},
+ {key:'status', label:'Status'},
+ {key:'cd', label:'Council District'},
+ {key:'zip', label:'ZIP'},
];
-let colOrder = LS.get('cols', ALLCOLS.map(c=>c.key));
-if(!Array.isArray(colOrder)||colOrder.length!==ALLCOLS.length) colOrder = ALLCOLS.map(c=>c.key);
-const colDef = k => ALLCOLS.find(c=>c.key===k);
-const cols = () => colOrder.map(colDef).filter(Boolean);
+const ERA_ORDER = {'2020-present':0,'2010-2019':1,'pre-2010':2};
+
+// ---- persisted UI prefs ----
+const LS = {
+ get sort(){return localStorage.getItem('lap.sort')||'newest';},
+ set sort(v){localStorage.setItem('lap.sort',v);},
+ get dens(){return localStorage.getItem('lap.dens')||'3';},
+ set dens(v){localStorage.setItem('lap.dens',v);},
+ get collapsed(){try{return JSON.parse(localStorage.getItem('lap.collapsed'))||{};}catch(e){return{};}},
+ set collapsed(o){localStorage.setItem('lap.collapsed',JSON.stringify(o));},
+};
-let state = { filters:{}, sort:LS.get('sort',{col:'lead_score',dir:'desc'}), page:1, rows:[], total:0 };
+// ---- state ----
+const state = {q:'',era:'',work_type:'',building_type:'',status:'',cd:'',zip:'',min_value:'',since:'',
+ sort:LS.sort, page:1, limit:60, total:0, loaded:0, busy:false};
+const FILTER_KEYS = ['q','era','work_type','building_type','status','cd','zip','min_value','since'];
-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});
-const scoreColor = s => s>=75?'#0f766e':s>=55?'#0d9488':s>=35?'#f59e0b':'#94a3b8';
+// ---- formatting ----
+const nfmt = new Intl.NumberFormat('en-US');
+const money = v=>{const n=Number(v); if(!n||n<=0) return null; return '$'+nfmt.format(Math.round(n));};
+const fmtDate = v=>{ if(!v) return '—'; const d=new Date(v); if(isNaN(d)) return '—';
+ return d.toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); };
+const statusClass = s=>{ const t=(s||'').toLowerCase();
+ if(/final|issued|cofo|complete|approved/.test(t)) return 'ok';
+ if(/expired|revoked|denied|withdrawn|cancel|void|abandon/.test(t)) return 'warn';
+ return 'mut'; };
+// Lead-score (0–100, db/lead_score.sql · permit_lead_score) → colour tier.
+const scoreColor = s=>{ s=Number(s); return s>=75?'#0f766e':s>=55?'#0d9488':s>=35?'#d97706':'#94a3b8'; };
+const rankBadge = s=>{ if(s==null||s==='') return ''; const n=Number(s);
+ return `<span class="rank" style="background:${scoreColor(n)}" title="Lead score ${n}/100"><b>${n}</b><span class="lbl">RANK</span></span>`; };
+const esc = s=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
-function qs(extra={}){
+function qs(extra, omit){
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);
+ FILTER_KEYS.forEach(k=>{ if(k!==omit && state[k]!=='' && state[k]!=null) p.set(k,state[k]); });
+ if(extra) Object.entries(extra).forEach(([k,v])=>p.set(k,v));
return p.toString();
}
+// ---- data loads ----
+// Sequence guards: a slower earlier request must never overwrite a newer render.
+let facetSeq=0, permitSeq=0;
async function loadFacets(){
- const d = await (await fetch('/api/facets?'+qs())).json();
- 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();
+ const my=++facetSeq;
+ try{
+ const r = await fetch(`${API}/api/facets?${qs()}`,AUTH);
+ const data = await r.json();
+ // A facet with an active selection would otherwise collapse to just that value
+ // (server applies every filter to every facet). Refetch each active dimension
+ // WITHOUT its own filter so its alternative values stay switchable.
+ const activeFacetKeys = FACETS.map(f=>f.key).filter(k=>state[k]!=='' && state[k]!=null);
+ await Promise.all(activeFacetKeys.map(async k=>{
+ try{ const alt = await (await fetch(`${API}/api/facets?${qs(null,k)}`,AUTH)).json(); data[k]=alt[k]; }catch(e){}
+ }));
+ if(my!==facetSeq) return; // superseded by a newer filter change
+ renderFacets(data);
+ }catch(e){ /* keep prior facets on error */ }
+}
+async function loadPermits(reset){
+ if(state.busy && !reset) return;
+ const my = reset ? ++permitSeq : permitSeq;
+ state.busy=true;
+ if(reset){ state.page=1; state.loaded=0; renderSkeleton(); }
+ else { $('#loadmore').disabled=true; $('#loadmore').textContent='Loading…'; }
+ try{
+ const r = await fetch(`${API}/api/permits?${qs({sort:state.sort,page:state.page,limit:state.limit})}`,AUTH);
+ const data = await r.json();
+ if(reset && my!==permitSeq) return; // a newer reset superseded this one
+ state.total = data.total; state.loaded += data.rows.length;
+ renderCards(data.rows, reset);
+ updateCount();
+ const more = state.loaded < state.total;
+ const lm=$('#loadmore');
+ lm.style.display = more ? '' : 'none';
+ lm.disabled=false; lm.textContent = `Load more (${nfmt.format(state.total-state.loaded)} more)`;
+ }catch(e){
+ if(reset) $('#grid').innerHTML = `<div class="empty">Failed to load permits — ${esc(e.message)}</div>`;
+ }finally{ state.busy=false; }
+}
+function reloadAll(){ loadPermits(true); loadFacets(); syncChips(); }
+
+// ---- render: facets ----
+function renderFacets(data){
+ const host=$('#facets'); const collapsed=LS.collapsed;
+ host.innerHTML='';
+ FACETS.forEach(({key,label})=>{
+ let vals=(data[key]||[]).slice();
+ if(key==='era') vals.sort((a,b)=>(ERA_ORDER[a.v]??9)-(ERA_ORDER[b.v]??9));
+ if(key==='cd') vals.sort((a,b)=>(+a.v||99)-(+b.v||99));
+ const sel = state[key];
+ const sec = el('div','facet'+(collapsed[key]?' collapsed':'')+(sel?' has-sel':''));
+ const hd = el('button','fhd',
+ `<span class="ttl"><span class="seldot"></span>${label}</span><span class="caret">▼</span>`);
+ hd.onclick=()=>{ const c=LS.collapsed; c[key]=!sec.classList.contains('collapsed'); LS.collapsed=c; sec.classList.toggle('collapsed'); };
+ const body=el('div','fbody');
+ vals.forEach(({v,c})=>{
+ const active = String(sel)===String(v);
+ const b=el('button','fv'+(active?' active':''),
+ `<span class="fname">${esc(v)}</span><span class="fc">${nfmt.format(c)}</span>`);
+ b.title=`${v} · ${nfmt.format(c)}`;
+ b.onclick=()=>{ setFilter(key, active?'':v); };
+ body.appendChild(b);
+ });
+ sec.appendChild(hd); sec.appendChild(body); host.appendChild(sec);
});
}
-async function loadRows(append=false){
- const d = await (await fetch('/api/permits?'+qs({page:state.page,limit:100}))).json();
- 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)':'');
- renderTable();
- $('#more').style.display = state.rows.length < state.total ? 'block' : 'none';
+function setFilter(key,val){
+ state[key]=val; state.page=1;
+ reloadAll();
}
-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);};
+// ---- render: cards ----
+function renderSkeleton(){ const g=$('#grid'); g.innerHTML=''; for(let i=0;i<9;i++) g.appendChild(el('div','skel')); }
+function renderCards(rows, reset){
+ const g=$('#grid');
+ if(reset){ g.innerHTML=''; if(!rows.length){ g.innerHTML='<div class="empty">No permits match these filters.</div>'; return; } }
+ rows.forEach(r=>g.appendChild(cardEl(r)));
+}
+function cardEl(r){
+ const c=el('button','card'); c.type='button';
+ const m=money(r.project_value);
+ const enrich=[];
+ if(r.assessed_value) enrich.push(`<span>Assessed <b>${money(r.assessed_value)||'—'}</b></span>`);
+ if(r.year_built && Number(r.year_built)>0) enrich.push(`<span>Built <b>${esc(r.year_built)}</b></span>`);
+ if(r.sqft && Number(r.sqft)>0) enrich.push(`<span><b>${nfmt.format(Number(r.sqft))}</b> sqft</span>`);
+ c.innerHTML=`
+ ${rankBadge(r.lead_score)}
+ <div class="addr">${esc(r.address||'—')}</div>
+ <div class="badges">
+ ${r.work_type?`<span class="badge wt">${esc(r.work_type)}</span>`:''}
+ ${r.building_type?`<span class="badge bt">${esc(r.building_type)}</span>`:''}
+ </div>
+ ${m?`<div class="val">${m}</div>`:`<div class="val zero">No stated value</div>`}
+ <div class="metarow">
+ <span>${fmtDate(r.issue_date)}</span>
+ <span class="pill ${statusClass(r.status)}">${esc(r.status||'—')}</span>
+ </div>
+ <div class="metarow">
+ <span class="era-tag">${esc(r.era||'')}</span>
+ <span>${r.cd?('CD '+esc(r.cd)):''}${r.zip?(' · '+esc(r.zip)):''}</span>
+ </div>
+ ${enrich.length?`<div class="subrow parcel">${enrich.join('')}</div>`:''}`;
+ c.onclick=()=>openDetail(r.permit_nbr, r);
+ return c;
+}
+function updateCount(){
+ $('#count').innerHTML = `<b>${nfmt.format(state.total)}</b> permit${state.total===1?'':'s'} · showing <b>${nfmt.format(state.loaded)}</b>`;
+}
+
+// ---- active chips ----
+function labelFor(k){return ({q:'Search',era:'Era',work_type:'Work',building_type:'Building',status:'Status',cd:'CD',zip:'ZIP',min_value:'Min $',since:'Since'})[k]||k;}
+function syncChips(){
+ const host=$('#chips'); host.innerHTML='';
+ const active = FILTER_KEYS.filter(k=>state[k]!=='' && state[k]!=null);
+ if(!active.length) return;
+ active.forEach(k=>{
+ let disp=state[k];
+ if(k==='min_value') disp='≥ $'+nfmt.format(state[k]);
+ if(k==='since') disp='since '+fmtDate(state[k]);
+ const chip=el('span','chip',`${labelFor(k)}: ${esc(disp)}<button title="Remove">×</button>`);
+ chip.querySelector('button').onclick=()=>clearFilter(k);
+ host.appendChild(chip);
});
+ const all=el('button','clr-all',null,'Clear all'); all.textContent='Clear all';
+ all.onclick=clearAll; host.appendChild(all);
}
-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 clearFilter(k){
+ state[k]='';
+ if(k==='q') $('#q').value='';
+ if(k==='min_value') $('#minval').value='';
+ if(k==='since') $('#since').value='';
+ reloadAll();
}
-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 clearAll(){
+ FILTER_KEYS.forEach(k=>state[k]='');
+ $('#q').value=''; $('#minval').value=''; $('#since').value='';
+ reloadAll();
}
-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));
+
+// ---- detail modal ----
+const modal=$('#modal'), scrim=$('#scrim');
+let detailScore=null; // lead_score carried from the card row (detail endpoint omits it)
+async function openDetail(nbr, fallback){
+ detailScore = (fallback && fallback.lead_score!=null) ? fallback.lead_score : null;
+ modal.classList.add('open'); scrim.classList.add('open');
+ $('#mAddr').textContent = fallback?.address || nbr;
+ $('#mSub').textContent = 'Loading permit '+nbr+'…';
+ $('#mBody').innerHTML = '<div class="loading">Loading detail…</div>';
+ applyModalRig();
+ try{
+ const r=await fetch(`${API}/api/permit/${encodeURIComponent(nbr)}`,AUTH);
+ if(!r.ok) throw new Error('HTTP '+r.status);
+ renderDetail(await r.json());
+ }catch(e){
+ $('#mBody').innerHTML=`<div class="empty">Couldn't load detail — ${esc(e.message)}</div>`;
}
}
-function renderBody(){
- const cs=cols();
- $('#rows').innerHTML = state.rows.map(r=>
- `<tr data-n="${escapeAttr(r.permit_nbr)}">`+cs.map(c=>`<td>${cell(c,r)}</td>`).join('')+`</tr>`
- ).join('');
- $('#rows').querySelectorAll('tr').forEach(tr=>tr.onclick=()=>openDetail(tr.dataset.n));
-}
-function renderTable(){ renderHead(); renderBody(); }
+function closeDetail(){ modal.classList.remove('open'); scrim.classList.remove('open'); }
+scrim.onclick=closeDetail; $('#mClose').onclick=closeDetail;
+document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDetail(); });
-async function openDetail(nbr){
- const r = await (await fetch('/api/permit/'+encodeURIComponent(nbr))).json();
- $('#mtitle').textContent = (r.primary_address||'Permit')+' — '+nbr;
+function kvFilter(k,v,text){ // clickable value that applies a filter and closes modal
+ return `<span class="flt" data-fk="${k}" data-fv="${esc(v)}">${esc(text!=null?text:v)}</span>`;
+}
+function renderDetail(d){
+ $('#mAddr').textContent = d.primary_address || d.permit_nbr;
+ $('#mSub').innerHTML = `${esc(d.permit_nbr)} · ${esc(d.era||'')}`;
+ const val=money(d.valuation);
+ const gmap = (d.lat&&d.lon)?`https://www.google.com/maps/search/?api=1&query=${d.lat},${d.lon}`:null;
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 map = (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>${map}
- <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';
+ ['Status', `<span class="pill ${statusClass(d.status_desc)}">${esc(d.status_desc||'—')}</span>`],
+ ['Issue date', fmtDate(d.issue_date)],
+ ['Work type', d.permit_type?kvFilter('work_type',d.permit_type):'—'],
+ ['Building type', d.permit_sub_type?kvFilter('building_type',d.permit_sub_type):'—'],
+ ['Permit group', esc(d.permit_group||'—')],
+ ['Use', esc(d.use_desc||'—')],
+ ['Council district', d.council_district?kvFilter('cd',d.council_district,'CD '+d.council_district):'—'],
+ ['ZIP', d.zip_code?kvFilter('zip',d.zip_code):'—'],
+ ['APN / parcel', esc(d.apn||'—')],
+ ];
+ const parcel=[];
+ if(d.parcel_assessed) parcel.push(['Assessed value', money(d.parcel_assessed)||'—']);
+ if(d.parcel_year_built && Number(d.parcel_year_built)>0) parcel.push(['Year built', esc(d.parcel_year_built)]);
+ if(d.parcel_sqft && Number(d.parcel_sqft)>0) parcel.push(['Living area', nfmt.format(Number(d.parcel_sqft))+' sqft']);
+ if(d.parcel_use) parcel.push(['Parcel use', esc(d.parcel_use)]);
+ if(d.parcel_address) parcel.push(['Parcel address', esc(d.parcel_address)]);
+
+ const scoreChip = detailScore!=null
+ ? `<span class="pill" style="background:${scoreColor(detailScore)};color:#fff;border-color:transparent;font-weight:700">Lead score ${Number(detailScore)}/100</span>` : '';
+ let html = `
+ <div class="m-val">${val?`<span class="big">${val}</span><span style="color:var(--ink-faint)">project valuation</span>`:`<span class="big" style="font-size:18px;color:var(--ink-faint)">No stated valuation</span>`}${scoreChip}</div>
+ <dl class="kv">${rows.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`;
+ if(d.work_desc){ html+=`<div class="sect">Scope of work</div><div class="desc">${esc(d.work_desc)}</div>`; }
+ if(parcel.length){ html+=`<div class="sect">Parcel enrichment (Assessor 2025)</div><dl class="kv">${parcel.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`; }
+ html+=`<div class="m-actions">
+ ${gmap?`<a class="m-btn primary" href="${gmap}" target="_blank" rel="noopener noreferrer">📍 Open in Google Maps</a>`:''}
+ ${d.source_url?`<a class="m-btn" href="${esc(d.source_url)}" target="_blank" rel="noopener noreferrer">Source record ↗</a>`:''}
+ <button class="m-btn" id="rawToggle">{ } Raw JSON</button>
+ </div>
+ <pre class="raw" id="rawBox">${esc(JSON.stringify(d.raw||d,null,2))}</pre>`;
+ const body=$('#mBody'); body.innerHTML=html;
+ body.querySelector('#rawToggle').onclick=e=>{ $('#rawBox').classList.toggle('open'); e.currentTarget.blur(); };
+ body.querySelectorAll('.flt').forEach(s=>s.onclick=()=>{ setFilter(s.dataset.fk, s.dataset.fv); closeDetail(); });
}
-$('#mx').onclick=$('#mbg').onclick=()=>$('#mbg').style.display=$('#modal').style.display='none';
-function reload(){ state.page=1; loadFacets(); loadRows(false); }
-$('#more').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');LS.set('density',e.target.value);};
-$('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
+// ---- modal rig: drag + resize + persist ----
+function applyModalRig(){
+ try{ const s=JSON.parse(localStorage.getItem('lap.modal'));
+ if(s){ modal.style.left=s.left; modal.style.top=s.top; modal.style.transform='none';
+ if(s.w) modal.style.width=s.w; if(s.h) modal.style.height=s.h; } }catch(e){}
+}
+(function rig(){
+ const hd=$('#mHd'), rz=$('#mRz'); let d=null;
+ const save=()=>localStorage.setItem('lap.modal',JSON.stringify({
+ left:modal.style.left,top:modal.style.top,w:modal.style.width,h:modal.style.height}));
+ hd.addEventListener('mousedown',e=>{ if(e.target.closest('.x'))return;
+ const r=modal.getBoundingClientRect(); modal.style.transform='none'; modal.style.left=r.left+'px'; modal.style.top=r.top+'px';
+ d={type:'move',x:e.clientX,y:e.clientY,l:r.left,t:r.top}; e.preventDefault(); });
+ rz.addEventListener('mousedown',e=>{ const r=modal.getBoundingClientRect();
+ d={type:'size',x:e.clientX,y:e.clientY,w:r.width,h:r.height}; e.preventDefault(); e.stopPropagation(); });
+ window.addEventListener('mousemove',e=>{ if(!d)return;
+ if(d.type==='move'){ modal.style.left=(d.l+e.clientX-d.x)+'px'; modal.style.top=Math.max(0,d.t+e.clientY-d.y)+'px'; }
+ else { modal.style.width=Math.max(340,d.w+e.clientX-d.x)+'px'; modal.style.height=Math.max(240,d.h+e.clientY-d.y)+'px'; } });
+ window.addEventListener('mouseup',()=>{ if(d){ save(); d=null; } });
+})();
-function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
-function escapeAttr(s){return escapeHtml(s).replace(/'/g,''')}
+// ---- top-bar wiring ----
+let qTimer;
+$('#q').addEventListener('input',e=>{ clearTimeout(qTimer); qTimer=setTimeout(()=>{ state.q=e.target.value.trim(); reloadAll(); },300); });
+$('#sort').addEventListener('change',e=>{ state.sort=e.target.value; LS.sort=e.target.value; loadPermits(true); });
+$('#dens').addEventListener('input',e=>{ document.documentElement.style.setProperty('--cols',e.target.value); LS.dens=e.target.value; });
+let mvTimer;
+$('#minval').addEventListener('input',e=>{ clearTimeout(mvTimer); mvTimer=setTimeout(()=>{ state.min_value=e.target.value?String(e.target.value):''; reloadAll(); },350); });
+$('#since').addEventListener('change',e=>{ state.since=e.target.value||''; reloadAll(); });
+$('#loadmore').addEventListener('click',()=>{ state.page++; loadPermits(false); });
-(function(){
- const d=LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px'); $('#density').value=d;
- loadFacets(); loadRows(false);
-})();
+// ---- boot ----
+$('#sort').value=state.sort;
+$('#dens').value=LS.dens; document.documentElement.style.setProperty('--cols',LS.dens);
+reloadAll();
</script>
</body>
</html>
diff --git a/viewer/public/table.html b/viewer/public/table.html
new file mode 100644
index 0000000..061dcce
--- /dev/null
+++ b/viewer/public/table.html
@@ -0,0 +1,253 @@
+<!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>
+<style>
+ :root{
+ --bg:#f6f7f9; --panel:#fff; --ink:#1a1f24; --muted:#6b7480; --line:#e3e7ec;
+ --teal:#0f766e; --teal-soft:#d7efec; --accent:#0e7490; --rowh:34px; --pad:6px 10px;
+ }
+ *{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;height:100vh}
+ header{grid-column:1/3;display:flex;align-items:center;gap:14px;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:230px}
+ .ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
+ 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:230px;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}
+ 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}
+ 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}
+ .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}
+ .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-bg{position:fixed;inset:0;background:rgba(20,25,30,.35);display:none;z-index:20}
+ .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:21;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}
+</style>
+</head>
+<body>
+<div class="app">
+ <header>
+ <h1>LA <b>Building Permits</b></h1>
+ <input id="q" type="search" placeholder="Search address…" autocomplete="off">
+ <span class="count" id="count">…</span>
+ <span class="grow"></span>
+ <span class="ctl">Row density <input id="density" type="range" min="26" max="46" value="34"></span>
+ <span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
+ </header>
+ <aside id="rail"></aside>
+ <main>
+ <table id="tbl">
+ <thead><tr id="head"></tr></thead>
+ <tbody id="rows"></tbody>
+ </table>
+ <button class="loadmore" id="more" style="display:none">Load more</button>
+ </main>
+</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>
+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)) };
+
+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);
+const colDef = k => ALLCOLS.find(c=>c.key===k);
+const cols = () => colOrder.map(colDef).filter(Boolean);
+
+let state = { filters:{}, sort:LS.get('sort',{col:'lead_score',dir:'desc'}), page:1, rows:[], total:0 };
+
+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});
+const scoreColor = s => s>=75?'#0f766e':s>=55?'#0d9488':s>=35?'#f59e0b':'#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/facets?'+qs())).json();
+ 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();
+ });
+}
+
+async function loadRows(append=false){
+ const d = await (await fetch('/api/permits?'+qs({page:state.page,limit:100}))).json();
+ 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)':'');
+ renderTable();
+ $('#more').style.display = state.rows.length < state.total ? 'block' : 'none';
+}
+
+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);};
+ });
+}
+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=>`<td>${cell(c,r)}</td>`).join('')+`</tr>`
+ ).join('');
+ $('#rows').querySelectorAll('tr').forEach(tr=>tr.onclick=()=>openDetail(tr.dataset.n));
+}
+function renderTable(){ renderHead(); renderBody(); }
+
+async function openDetail(nbr){
+ const r = await (await fetch('/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 map = (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>${map}
+ <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=()=>{ 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');LS.set('density',e.target.value);};
+$('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
+
+function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
+function escapeAttr(s){return escapeHtml(s).replace(/'/g,''')}
+
+(function(){
+ const d=LS.get('density','34'); document.documentElement.style.setProperty('--rowh',d+'px'); $('#density').value=d;
+ loadFacets(); loadRows(false);
+})();
+</script>
+</body>
+</html>
← 2efe81b Viewer: VIEWER_NO_AUTH env bypass for local screenshot/dev (
·
back to La Socrata Ingester
·
auto-data-snapshot: 2026-08-11T09:10:52 (1 data files) — db/ bc01699 →