← back to La Socrata Ingester
viewer/public/table.html
276 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>
<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 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,#6b7480);line-height:1.5}
footer.disclaimer a{color:var(--accent,#0e7490);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,#0f766e)}
footer.disclaimer .full{margin-top:6px;max-width:940px}
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>
<footer class="disclaimer">
<details>
<summary>ⓘ Data from LA City & LA County public records — provided “as is,” not independently verified. <b>Sources & terms ▸</b></summary>
<div class="full">
<b>Sources.</b> Building permits: City of Los Angeles, Dept. of Building & Safety, via the
<a href="https://data.lacity.org" target="_blank" rel="noopener noreferrer">LA City Open Data portal</a>.
Parcel & 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>“as is,” 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.
</div>
</details>
</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>
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>