← back to La Socrata Ingester
viewer/public/index.html
646 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LA Building Permits</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
:root{
--bg:#f6f7f9; --panel:#fff; --ink:#1a1f24; --muted:#6b7480; --line:#e3e7ec;
--teal:#0f766e; --teal-soft:#d7efec; --accent:#0e7490; --rowh:34px; --pad:6px 10px; --cols:4;
}
*{box-sizing:border-box}
body{margin:0;font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
color:var(--ink);background:var(--bg);height:100vh;overflow:hidden}
.app{display:grid;grid-template-columns:250px 1fr;grid-template-rows:auto 1fr auto;height:100vh}
footer.disclaimer{grid-column:1/3;border-top:1px solid var(--line);background:var(--panel);
padding:6px 16px;font-size:11px;color:var(--muted);line-height:1.5}
footer.disclaimer a{color:var(--accent);text-decoration:none}
footer.disclaimer a:hover{text-decoration:underline}
footer.disclaimer summary{cursor:pointer;list-style:none;outline:none}
footer.disclaimer summary::-webkit-details-marker{display:none}
footer.disclaimer summary b{color:var(--teal)}
footer.disclaimer .full{margin-top:6px;max-width:940px}
header{grid-column:1/3;display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);
border-bottom:1px solid var(--line);flex-wrap:wrap}
header h1{font-size:15px;margin:0;font-weight:650;letter-spacing:-.2px}
header h1 b{color:var(--teal)}
.count{color:var(--muted);font-size:12px}
.grow{flex:1}
input,select{font:inherit;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
input#q{width:210px}
.ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.views{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.views button{border:none;background:#fff;padding:6px 12px;font:inherit;cursor:pointer;color:var(--muted);border-left:1px solid var(--line)}
.views button:first-child{border-left:none}
.views button.on{background:var(--teal);color:#fff;font-weight:600}
aside{background:var(--panel);border-right:1px solid var(--line);overflow:auto;padding:8px 0}
.facet{border-bottom:1px solid var(--line)}
.facet h3{margin:0;padding:9px 14px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
cursor:pointer;display:flex;justify-content:space-between;user-select:none}
.facet h3::after{content:"▾";font-size:9px;opacity:.5}
.facet.collapsed h3::after{content:"▸"}
.facet ul{list-style:none;margin:0;padding:0 0 6px;max-height:220px;overflow:auto}
.facet.collapsed ul{display:none}
.facet li{display:flex;justify-content:space-between;gap:8px;padding:4px 14px;cursor:pointer;font-size:12px}
.facet li:hover{background:#f0f3f5}
.facet li.active{background:var(--teal-soft);color:var(--teal);font-weight:600}
.facet li .c{color:var(--muted);font-variant-numeric:tabular-nums;font-size:11px}
.facet li.active .c{color:var(--teal)}
main{overflow:auto;position:relative}
/* list/table */
table{border-collapse:separate;border-spacing:0;width:100%;background:var(--panel)}
thead th{position:sticky;top:0;z-index:2;background:#eef1f4;border-bottom:1px solid var(--line);
text-align:left;padding:var(--pad);font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#495057;
cursor:pointer;white-space:nowrap;user-select:none}
thead th:hover{background:#e5e9ee}
thead th.drag{opacity:.4}
thead th .arrow{color:var(--teal);margin-left:3px}
thead th .grip{color:#b7bfc7;margin-right:5px;cursor:grab}
.filterrow th{position:sticky;top:28px;z-index:1;background:#f6f7f9;border-bottom:1px solid var(--line);
padding:3px 6px;cursor:default}
.filterrow th:hover{background:#f6f7f9}
.filterrow input{width:100%;min-width:52px;padding:3px 6px;font-size:11px;border:1px solid var(--line);
border-radius:5px;background:#fff;color:var(--ink)}
.filterrow input:focus{outline:none;border-color:var(--teal)}
.filterrow th.nofilter{background:#f6f7f9}
tbody td{padding:var(--pad);border-bottom:1px solid var(--line);white-space:nowrap;height:var(--rowh);
max-width:340px;overflow:hidden;text-overflow:ellipsis}
tbody tr:hover{background:#f0f9f8;cursor:pointer}
tbody td.swatch-cell{border-left:4px solid var(--sw,transparent)}
.num{text-align:right;font-variant-numeric:tabular-nums}
.addr{font-weight:600}
.badge{display:inline-block;padding:1px 7px;border-radius:20px;font-size:11px;font-weight:600;background:#eef1f4;color:#495057}
.score{display:inline-block;min-width:34px;text-align:center;padding:2px 7px;border-radius:6px;font-weight:700;font-variant-numeric:tabular-nums;color:#fff}
.pill{padding:1px 8px;border-radius:20px;font-size:11px;font-weight:600}
.pill.Issued{background:#dcfce7;color:#15803d}.pill.Finaled{background:#f1f5f9;color:#64748b}
.pill.Expired{background:#fee2e2;color:#b91c1c}.pill.other{background:#f1f5f9;color:#64748b}
/* grid */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:14px}
.card{border:1px solid var(--line);border-left:5px solid var(--sw,#ccc);border-radius:10px;background:#fff;padding:12px 13px;cursor:pointer;transition:box-shadow .1s}
.card:hover{box-shadow:0 4px 14px rgba(16,24,40,.1)}
.card .top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
.card .a{font-weight:650;font-size:13px;line-height:1.25}
.card .meta{color:var(--muted);font-size:11px;margin-top:5px;display:flex;flex-wrap:wrap;gap:5px 10px}
.card .money{font-weight:700;color:var(--teal);font-variant-numeric:tabular-nums}
.card .when{color:var(--muted);font-size:11px;margin-top:6px}
/* map */
#map{height:100%;width:100%}
.mapwrap{position:absolute;inset:0}
.mapjump{position:absolute;top:10px;left:52px;z-index:500;background:#fff;border:1px solid var(--line);border-radius:8px;
box-shadow:0 2px 8px rgba(0,0,0,.12);padding:5px 8px;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
.mapjump select{font-size:11px;padding:3px 6px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);max-width:150px}
.legend{position:absolute;bottom:14px;right:14px;z-index:500;background:#fff;border:1px solid var(--line);border-radius:8px;padding:8px 11px;font-size:11px;box-shadow:0 2px 8px rgba(0,0,0,.12)}
.legend b{display:block;margin-bottom:5px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
.legend .row{display:flex;align-items:center;gap:6px;margin:2px 0}
.legend .dot{width:11px;height:11px;border-radius:50%}
.loadmore{display:block;margin:14px auto 40px;padding:9px 22px;border:1px solid var(--teal);background:#fff;color:var(--teal);
border-radius:8px;font-weight:600;cursor:pointer}
.loadmore:hover{background:var(--teal);color:#fff}
/* modal */
.modal-bg{position:fixed;inset:0;background:rgba(20,25,30,.35);display:none;z-index:1000}
.modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(680px,92vw);max-height:86vh;overflow:auto;
background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);z-index:1001;display:none}
.modal header{display:flex;justify-content:space-between;align-items:center;position:sticky;top:0}
.modal h2{font-size:15px;margin:0}
.modal .x{cursor:pointer;font-size:20px;color:var(--muted);border:none;background:none}
.modal .body{padding:14px 18px}
.kv{display:grid;grid-template-columns:150px 1fr;gap:4px 14px;font-size:13px;margin-bottom:14px}
.kv .k{color:var(--muted)}
.modal a.map{display:inline-block;margin:2px 0 12px;color:var(--teal);font-weight:600;text-decoration:none}
pre{background:#0f1720;color:#c5e4de;padding:12px;border-radius:8px;overflow:auto;font-size:11px;max-height:280px}
.rawtoggle{cursor:pointer;color:var(--accent);font-size:12px;user-select:none}
.hidden{display:none!important}
/* status overlay: loading + empty state */
.overlay{position:absolute;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
background:rgba(246,247,249,.72);backdrop-filter:blur(1px)}
.overlay .box{text-align:center;color:var(--muted);font-size:13px}
.overlay .box .big{font-size:15px;font-weight:650;color:var(--ink);margin-bottom:4px}
.overlay .box .sub{margin-bottom:10px}
.overlay .box button{border:1px solid var(--teal);background:#fff;color:var(--teal);border-radius:8px;
padding:6px 16px;font:inherit;font-weight:600;cursor:pointer}
.overlay .box button:hover{background:var(--teal);color:#fff}
.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--teal);border-radius:50%;
margin:0 auto 10px;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* column show/hide popover */
.colspop{position:absolute;top:34px;right:0;z-index:70;background:#fff;border:1px solid var(--line);border-radius:8px;
box-shadow:0 8px 24px rgba(16,24,40,.14);padding:6px;min-width:186px;max-height:340px;overflow:auto}
.colspop .hd{display:flex;justify-content:space-between;align-items:center;padding:4px 8px 6px;border-bottom:1px solid var(--line);margin-bottom:4px}
.colspop .hd span:first-child{color:var(--muted);font-size:11px;letter-spacing:.05em}
.colspop .hd a{color:var(--accent);font-size:11px;cursor:pointer;text-decoration:none}
.colspop .hd a:hover{text-decoration:underline}
.colspop label{display:flex;align-items:center;gap:7px;padding:4px 8px;font-size:12px;cursor:pointer;border-radius:6px;white-space:nowrap}
.colspop label:hover{background:#f0f3f5}
kbd{font:11px/1 ui-monospace,Menlo,monospace;background:#eef1f4;border:1px solid var(--line);border-bottom-width:2px;
border-radius:4px;padding:1px 5px;color:#495057}
#savedpop{min-width:236px}
.colspop .savedrow{display:flex;justify-content:space-between;align-items:center}
.colspop .savedrow a.savedapply{flex:1;padding:5px 8px;cursor:pointer;border-radius:6px;color:var(--ink);text-decoration:none;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.colspop .savedrow a.savedapply:hover{background:#f0f3f5}
.colspop .savedrow a.saveddel{padding:5px 8px;color:var(--muted);cursor:pointer;text-decoration:none}
.colspop .savedrow a.saveddel:hover{color:#dc2626}
</style>
</head>
<body>
<div class="app">
<header>
<h1>LA <b>Building Permits</b></h1>
<input id="q" type="search" placeholder="Search address… (press / )" autocomplete="off">
<span class="count" id="count">…</span>
<span class="grow"></span>
<div class="views" id="views">
<button data-v="list" class="on">☰ List</button>
<button data-v="grid">▦ Grid</button>
<button data-v="map">🗺 Map</button>
</div>
<span class="ctl">Color <select id="colorby">
<option value="lead_score">Lead score</option>
<option value="status">Status</option>
<option value="project_value">Project $</option>
<option value="work_type">Work type</option>
</select></span>
<span class="ctl" id="densctl">Density <input id="density" type="range" min="26" max="46" value="34"></span>
<span class="ctl" id="savedwrap" style="position:relative"><button id="savedbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Save the current filters/sort/layout as a named view, or apply a saved one">📑 Views</button><div id="savedpop" class="colspop hidden"></div></span>
<span class="ctl" id="colswrap" style="position:relative"><button id="colsbtn" class="loadmore" style="margin:0;padding:5px 12px" title="Show / hide table columns">▦ Columns</button><div id="colspop" class="colspop hidden"></div></span>
<span class="ctl"><button id="csv" class="loadmore" style="margin:0;padding:5px 12px" title="Download the current filtered + sorted results as a CSV (up to 5,000 rows)">⬇ CSV</button></span>
<span class="ctl"><button id="copylink" class="loadmore" style="margin:0;padding:5px 12px" title="Copy a shareable link to this exact view (filters + sort + view + color)">🔗 Copy link</button></span>
<span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
</header>
<aside id="rail"></aside>
<main>
<div id="overlay" class="overlay hidden"></div>
<div id="listview">
<table id="tbl"><thead><tr id="head"></tr><tr id="filterrow" class="filterrow"></tr></thead><tbody id="rows"></tbody></table>
<div id="dls" hidden></div>
<button class="loadmore" id="more" style="display:none">Load more</button>
</div>
<div id="gridview" class="hidden">
<div class="grid" id="cards"></div>
<button class="loadmore" id="more2" style="display:none">Load more</button>
</div>
<div id="mapview" class="mapwrap hidden">
<div id="map"></div>
<div class="mapjump" id="mapjump">
<span>Jump to</span>
<select id="jumpcd"><option value="">CD…</option></select>
<select id="jumpzip"><option value="">ZIP…</option></select>
</div>
<div class="legend" id="legend"></div>
</div>
</main>
<footer class="disclaimer">
<details>
<summary>ⓘ Data from LA City & 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.<br>
<b>Site.</b> <a href="/terms.html">Terms of Use</a> · <a href="/privacy.html">Privacy Policy</a>. Map tiles © OpenStreetMap contributors.
</div>
</details>
<span style="white-space:nowrap"><a href="/terms.html">Terms</a> · <a href="/privacy.html">Privacy</a></span>
</footer>
</div>
<div class="modal-bg" id="mbg"></div>
<div class="modal" id="modal">
<header style="padding:14px 18px;border-bottom:1px solid var(--line)">
<h2 id="mtitle">Permit</h2><button class="x" id="mx">×</button>
</header>
<div class="body" id="mbody"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const $ = s => document.querySelector(s);
const LS = { get:(k,d)=>{try{const v=localStorage.getItem('lap_'+k);return v==null?d:JSON.parse(v)}catch{return d}}, set:(k,v)=>localStorage.setItem('lap_'+k,JSON.stringify(v)) };
// Absolute same-origin API base — fetch() rejects relative paths when the page URL carries embedded Basic-Auth creds ("URL that includes credentials"); location.origin is credential-free.
const api = path => location.origin + path;
const ALLCOLS = [
{key:'lead_score', label:'Rank', type:'score'},
{key:'issued', label:'Issued', type:'date'},
{key:'address', label:'Address', type:'addr'},
{key:'zip', label:'ZIP', type:'t'},
{key:'cd', label:'CD', type:'t'},
{key:'work_type', label:'Work Type', type:'badge'},
{key:'building_type', label:'Building Type', type:'badge'},
{key:'status', label:'Status', type:'status'},
{key:'project_value', label:'Project $', type:'money'},
{key:'assessed_value', label:'Assessed $', type:'money'},
{key:'year_built', label:'Yr Built', type:'t'},
{key:'sqft', label:'SqFt', type:'int'},
{key:'use_desc', label:'Use', type:'t'},
{key:'era', label:'Era', type:'t'},
{key:'permit_nbr', label:'Permit #', type:'t'},
];
let colOrder = LS.get('cols', ALLCOLS.map(c=>c.key));
if(!Array.isArray(colOrder)||colOrder.length!==ALLCOLS.length) colOrder = ALLCOLS.map(c=>c.key);
let colHidden = new Set(LS.get('hidden', []));
const colDef = k => ALLCOLS.find(c=>c.key===k);
const cols = () => colOrder.map(colDef).filter(c=>c && !colHidden.has(c.key));
let state = {
filters:{}, sort:LS.get('sort',{col:'lead_score',dir:'desc'}), page:1, rows:[], total:0,
view:LS.get('view','list'), colorBy:LS.get('colorby','lead_score'),
};
const FACETS = [['era','Era'],['work_type','Work Type'],['building_type','Building Type'],['status','Status'],['cd','Council District'],['zip','ZIP']];
const money = v => v==null?'':'$'+Number(v).toLocaleString(undefined,{maximumFractionDigits:0});
// ---- COLOR ID: one color model driving list / grid / map ----
const scoreColor = s => s>=75?'#0f766e':s>=55?'#16a34a':s>=35?'#f59e0b':'#94a3b8';
const WORK_COLORS = {'Bldg-New':'#0f766e','Bldg-Addition':'#2563eb','Bldg-Alter/Repair':'#f59e0b','Swimming-Pool/Spa':'#06b6d4','Bldg-Demolition':'#dc2626','Grading':'#9333ea'};
function valueColor(v){ v=Number(v)||0; return v>=2e6?'#0f766e':v>=1e6?'#16a34a':v>=500e3?'#65a30d':v>=250e3?'#f59e0b':v>=100e3?'#f97316':'#94a3b8'; }
function statusColor(s){ return /Issued/.test(s)?'#16a34a':/Final/.test(s)?'#64748b':/Expired/.test(s)?'#dc2626':'#94a3b8'; }
function colorFor(r){
switch(state.colorBy){
case 'status': return statusColor(r.status);
case 'project_value': return valueColor(r.project_value);
case 'work_type': return WORK_COLORS[r.work_type]||'#94a3b8';
default: return scoreColor(r.lead_score);
}
}
function legendItems(){
switch(state.colorBy){
case 'status': return [['Issued','#16a34a'],['Finaled','#64748b'],['Expired','#dc2626'],['Other','#94a3b8']];
case 'project_value': return [['≥ $2M','#0f766e'],['$1–2M','#16a34a'],['$500k–1M','#65a30d'],['$250–500k','#f59e0b'],['$100–250k','#f97316'],['< $100k','#94a3b8']];
case 'work_type': return Object.entries(WORK_COLORS).map(([k,v])=>[k,v]).concat([['Other','#94a3b8']]);
default: return [['75–100','#0f766e'],['55–74','#16a34a'],['35–54','#f59e0b'],['0–34','#94a3b8']];
}
}
function qs(extra={}){
const p=new URLSearchParams();
for(const [k,v] of Object.entries(state.filters)) if(v) p.set(k,v);
p.set('sort',state.sort.col); p.set('dir',state.sort.dir);
for(const [k,v] of Object.entries(extra)) p.set(k,v);
return p.toString();
}
async function loadFacets(){
const d = await (await fetch(api('/api/facets?'+qs()))).json();
facetData = d; renderDatalists(); // feed the per-column filter datalists
const rail = $('#rail'); const collapsed = LS.get('collapsed',{});
rail.innerHTML = FACETS.map(([key,label])=>{
const items=(d[key]||[]).map(o=>{
const active = String(state.filters[key]||'')===String(o.v);
return `<li data-k="${key}" data-v="${escapeAttr(o.v)}" class="${active?'active':''}"><span>${escapeHtml(o.v)}</span><span class="c">${Number(o.c).toLocaleString()}</span></li>`;
}).join('');
return `<div class="facet ${collapsed[key]?'collapsed':''}" data-f="${key}"><h3>${label}<span></span></h3><ul>${items}</ul></div>`;
}).join('');
rail.querySelectorAll('.facet h3').forEach(h=>h.onclick=()=>{const f=h.parentElement.dataset.f;const c=LS.get('collapsed',{});c[f]=!c[f];LS.set('collapsed',c);h.parentElement.classList.toggle('collapsed');});
rail.querySelectorAll('li').forEach(li=>li.onclick=()=>{ const {k,v}=li.dataset; state.filters[k]=String(state.filters[k]||'')===v?'':v; reload(); });
}
// ---- status overlay (loading + empty state) ----
const overlay = () => $('#overlay');
function showLoading(){ overlay().innerHTML='<div class="box"><div class="spin"></div>Loading permits…</div>'; overlay().classList.remove('hidden'); }
function showEmpty(){ const filtered=Object.values(state.filters).some(Boolean);
overlay().innerHTML=`<div class="box"><div class="big">No permits match these filters</div>`+
(filtered?`<div class="sub">Try widening or clearing a filter.</div><button id="emptyreset">Clear all filters</button>`:`<div class="sub">Nothing to show.</div>`)+`</div>`;
overlay().classList.remove('hidden');
const b=$('#emptyreset'); if(b) b.onclick=()=>{ state.filters={}; $('#q').value=''; reload(); };
}
function hideOverlay(){ overlay().classList.add('hidden'); }
async function loadRows(append=false){
if(!append) showLoading();
const limit = state.view==='map' ? 1500 : 100;
let d;
try{ d = await (await fetch(api('/api/permits?'+qs({page:state.page,limit})))).json(); }
catch(e){ overlay().innerHTML='<div class="box"><div class="big">Couldn’t load permits</div><div class="sub">'+escapeHtml(e.message||'network error')+'</div></div>'; overlay().classList.remove('hidden'); return; }
(d.rows||[]).forEach(r=>{ if(r.issued==null) r.issued=r.issue_date; }); // API sends issue_date; the List/Grid/CSV column key is `issued`
state.total = d.total;
state.rows = append ? state.rows.concat(d.rows) : d.rows;
$('#count').textContent = state.total.toLocaleString()+' permits'+(Object.values(state.filters).some(Boolean)?' (filtered)':'');
if(!append && state.rows.length===0) showEmpty(); else hideOverlay();
render();
const hasMore = state.rows.length < state.total && state.view!=='map';
$('#more').style.display = (state.view==='list'&&hasMore)?'block':'none';
$('#more2').style.display = (state.view==='grid'&&hasMore)?'block':'none';
}
// ---- LIST ----
function renderHead(){
const head=$('#head');
head.innerHTML = cols().map(c=>{
const arr = state.sort.col===c.key ? `<span class="arrow">${state.sort.dir==='asc'?'▲':'▼'}</span>` : '';
return `<th draggable="true" data-k="${c.key}"><span class="grip">⠿</span>${c.label}${arr}</th>`;
}).join('');
head.querySelectorAll('th').forEach(th=>{
th.onclick=e=>{ if(e.target.classList.contains('grip'))return; sortBy(th.dataset.k); };
th.ondragstart=()=>{drag=th.dataset.k;th.classList.add('drag');};
th.ondragend=()=>th.classList.remove('drag');
th.ondragover=e=>e.preventDefault();
th.ondrop=e=>{e.preventDefault();dropCol(th.dataset.k);};
});
renderFilterRow();
}
// per-column filters map each column to a server filter param (reuses state.filters + qs())
const COLFILTER = {
issued: {param:'since', type:'date', ph:'on/after'},
address: {param:'q', type:'search', ph:'contains…'},
zip: {param:'zip', type:'text', ph:'ZIP', list:'zip'},
cd: {param:'cd', type:'text', ph:'CD', list:'cd'},
work_type: {param:'work_type', type:'text', ph:'any', list:'work_type'},
building_type: {param:'building_type',type:'text', ph:'any', list:'building_type'},
status: {param:'status', type:'text', ph:'any', list:'status'},
era: {param:'era', type:'text', ph:'any', list:'era'},
project_value: {param:'min_value', type:'number', ph:'≥ $'},
};
let facetData = {};
function renderFilterRow(){
const fr=$('#filterrow'); if(!fr) return;
const act=document.activeElement, actP=(act&&act.dataset&&act.dataset.p)?act.dataset.p:null;
const selPos = actP ? act.selectionStart : null;
fr.innerHTML = cols().map(c=>{
const f=COLFILTER[c.key];
if(!f) return `<th class="nofilter"></th>`;
const v = state.filters[f.param]!=null ? escapeAttr(state.filters[f.param]) : '';
return `<th><input data-p="${f.param}" type="${f.type}" placeholder="${f.ph}" value="${v}"${f.list?` list="dl_${f.param}"`:''}></th>`;
}).join('');
fr.querySelectorAll('input').forEach(inp=>{
const debounce = (inp.type==='date') ? 0 : 300;
inp.oninput=()=>{ clearTimeout(inp._t); inp._t=setTimeout(()=>{
const p=inp.dataset.p, v=inp.value.trim();
if(v) state.filters[p]=v; else delete state.filters[p];
if(p==='q') $('#q').value=v; // keep top search box in sync
reload();
}, debounce); };
});
if(actP){ const el=fr.querySelector(`input[data-p="${actP}"]`); if(el){ el.focus(); try{ el.setSelectionRange(selPos,selPos); }catch(e){} } }
renderDatalists();
}
function renderDatalists(){
const h=$('#dls'); if(!h) return;
const mk=(param,key)=>`<datalist id="dl_${param}">`+((facetData[key]||[]).map(o=>`<option value="${escapeAttr(o.v)}">`).join(''))+`</datalist>`;
h.innerHTML = mk('zip','zip')+mk('cd','cd')+mk('work_type','work_type')+mk('building_type','building_type')+mk('status','status')+mk('era','era');
}
let drag=null;
function dropCol(target){ if(!drag||drag===target)return; const a=colOrder.indexOf(drag),b=colOrder.indexOf(target);
colOrder.splice(a,1); colOrder.splice(b,0,drag); LS.set('cols',colOrder); drag=null; renderHead(); renderBody(); }
function sortBy(col){
if(state.sort.col===col) state.sort.dir = state.sort.dir==='asc'?'desc':'asc';
else state.sort = {col, dir:['address','zip','use_desc','permit_nbr','work_type','building_type','status','era'].includes(col)?'asc':'desc'};
LS.set('sort',state.sort); state.page=1; loadRows(false);
}
function cell(c,r){
const v=r[c.key];
switch(c.type){
case 'score': return `<span class="score" style="background:${scoreColor(v)}">${v==null?'':v}</span>`;
case 'date': return v?new Date(v).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'';
case 'money': return `<span class="num">${money(v)}</span>`;
case 'int': return `<span class="num">${v==null?'':Number(v).toLocaleString()}</span>`;
case 'addr': return `<span class="addr">${escapeHtml(v||'')}</span>`;
case 'badge': return v?`<span class="badge">${escapeHtml(v)}</span>`:'';
case 'status': {const cls=/Issued/.test(v)?'Issued':/Final/.test(v)?'Finaled':/Expired/.test(v)?'Expired':'other';return v?`<span class="pill ${cls}">${escapeHtml(v)}</span>`:''}
default: return escapeHtml(v==null?'':String(v));
}
}
function renderBody(){
const cs=cols();
$('#rows').innerHTML = state.rows.map(r=>
`<tr data-n="${escapeAttr(r.permit_nbr)}">`+cs.map((c,i)=>
`<td class="${i===0?'swatch-cell':''}" ${i===0?`style="--sw:${colorFor(r)}"`:''}>${cell(c,r)}</td>`).join('')+`</tr>`
).join('');
$('#rows').querySelectorAll('tr').forEach(tr=>tr.onclick=()=>openDetail(tr.dataset.n));
}
// ---- GRID ----
function renderCards(){
$('#cards').innerHTML = state.rows.map(r=>`
<div class="card" data-n="${escapeAttr(r.permit_nbr)}" style="--sw:${colorFor(r)}">
<div class="top"><div class="a">${escapeHtml(r.address||'')}</div>
<span class="score" style="background:${scoreColor(r.lead_score)}">${r.lead_score??''}</span></div>
<div class="meta">${r.work_type?`<span class="badge">${escapeHtml(r.work_type)}</span>`:''}
${r.building_type?`<span>${escapeHtml(r.building_type)}</span>`:''}<span>CD ${escapeHtml(r.cd||'—')}</span><span>${escapeHtml(r.zip||'')}</span></div>
<div class="meta"><span class="money">${money(r.project_value)}</span>${r.assessed_value?`<span>assessed ${money(r.assessed_value)}</span>`:''}${r.year_built?`<span>built ${escapeHtml(r.year_built)}</span>`:''}</div>
<div class="when">${r.issued?new Date(r.issued).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):''} · ${escapeHtml(r.status||'')}</div>
</div>`).join('');
$('#cards').querySelectorAll('.card').forEach(c=>c.onclick=()=>openDetail(c.dataset.n));
}
// ---- MAP ----
let map=null, layer=null;
function renderMap(){
if(!map){
map = L.map('map',{preferCanvas:true}).setView([34.02,-118.35],10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
attribution:'© OpenStreetMap contributors', maxZoom:19}).addTo(map);
}
if(layer) layer.remove();
layer = L.layerGroup();
const pts=[];
for(const r of state.rows){
if(r.lat==null||r.lon==null) continue;
const m = L.circleMarker([r.lat,r.lon],{radius:5,color:'#fff',weight:1,fillColor:colorFor(r),fillOpacity:.9});
m.on('click',()=>openDetail(r.permit_nbr));
m.bindTooltip(`${escapeHtml(r.address||'')} · ${money(r.project_value)} · score ${r.lead_score??''}`);
layer.addLayer(m); pts.push([r.lat,r.lon]);
}
layer.addTo(map);
if(pts.length) map.fitBounds(pts,{padding:[30,30],maxZoom:14});
$('#legend').innerHTML = `<b>${$('#colorby').selectedOptions[0].text} · ${state.rows.filter(r=>r.lat).length} shown</b>`+
legendItems().map(([l,c])=>`<div class="row"><span class="dot" style="background:${c}"></span>${escapeHtml(l)}</div>`).join('');
renderMapJump();
setTimeout(()=>map.invalidateSize(),50);
}
// ---- CD / ZIP quick-jump on the map (filters + the existing fitBounds flies there) ----
let allFacets = {}; // stable full CD/ZIP lists (unfiltered), so the jump menu always offers every value
function renderMapJump(){
const cd=$('#jumpcd'), zip=$('#jumpzip'); if(!cd||!zip) return;
const jf = k => (allFacets[k]&&allFacets[k].length)?allFacets[k]:(facetData[k]||[]);
const fill=(sel,items,label,cur)=>{ sel.innerHTML=`<option value="">${label}</option>`+
(items||[]).map(o=>`<option value="${escapeAttr(o.v)}" ${String(cur||'')===String(o.v)?'selected':''}>${escapeHtml(o.v)} (${Number(o.c).toLocaleString()})</option>`).join(''); };
fill(cd, jf('cd'), 'CD…', state.filters.cd);
fill(zip, jf('zip'), 'ZIP…', state.filters.zip);
}
$('#jumpcd').onchange=e=>{ const v=e.target.value; if(v) state.filters.cd=v; else delete state.filters.cd; reload(); };
$('#jumpzip').onchange=e=>{ const v=e.target.value; if(v) state.filters.zip=v; else delete state.filters.zip; reload(); };
function syncURL(){ // mirror filters+sort+view+color into the URL so any view is shareable/bookmarkable
const p=new URLSearchParams();
for(const [k,v] of Object.entries(state.filters)) if(v) p.set(k,v);
if(state.sort&&state.sort.col){ p.set('sort',state.sort.col); p.set('dir',state.sort.dir); }
if(state.view&&state.view!=='list') p.set('view',state.view);
if(state.colorBy&&state.colorBy!=='lead_score') p.set('color',state.colorBy);
const dens=$('#density').value; if(dens && dens!=='34') p.set('d',dens); // density
const visKeys=cols().map(c=>c.key), defKeys=ALLCOLS.map(c=>c.key); // visible column order (encodes both hidden + reorder)
if(visKeys.join(',')!==defKeys.join(',')) p.set('cols',visKeys.join(','));
const s=p.toString();
try{ history.replaceState(null,'', s?('?'+s):location.pathname); }catch(e){}
}
function render(){
syncURL();
$('#listview').classList.toggle('hidden', state.view!=='list');
$('#gridview').classList.toggle('hidden', state.view!=='grid');
$('#mapview').classList.toggle('hidden', state.view!=='map');
$('#densctl').style.display = state.view==='map'?'none':'flex';
$('#colswrap').style.display = state.view==='list'?'inline-flex':'none'; // columns menu is list-only
if(state.view!=='list') $('#colspop').classList.add('hidden');
if(state.view==='list'){ renderHead(); renderBody(); }
else if(state.view==='grid') renderCards();
else renderMap();
}
async function openDetail(nbr){
const r = await (await fetch(api('/api/permit/'+encodeURIComponent(nbr)))).json();
$('#mtitle').textContent = (r.primary_address||'Permit')+' — '+nbr;
const rows=[
['Lead score', r.lead_score], ['Issued', r.issue_date?new Date(r.issue_date).toLocaleString():''],
['Work type', r.permit_type], ['Building type', r.permit_sub_type], ['Use', r.use_desc],
['Status', r.status_desc], ['Project value', money(r.valuation)], ['Council district', r.cd],
['ZIP', r.zip_code], ['Era', r.era], ['APN', r.apn],
['— Parcel (2025) —',''], ['Assessed value', money(r.parcel_assessed)], ['Year built', r.parcel_year_built],
['SqFt', r.parcel_sqft], ['Use type', r.parcel_use], ['Parcel address', r.parcel_address],
['Scope', r.work_desc],
].filter(([k,v])=>v!=null&&v!=='');
const mp = (r.lat&&r.lon)?`<a class="map" target="_blank" rel="noopener" href="https://www.google.com/maps?q=${r.lat},${r.lon}">📍 Open in Google Maps</a>`:'';
$('#mbody').innerHTML = `<div class="kv">${rows.map(([k,v])=>`<div class="k">${escapeHtml(k)}</div><div>${escapeHtml(String(v))}</div>`).join('')}</div>${mp}
<div class="rawtoggle" id="rawt">▸ Raw record</div><pre id="raw" style="display:none">${escapeHtml(JSON.stringify(r.raw,null,2))}</pre>`;
$('#rawt').onclick=()=>{const p=$('#raw');p.style.display=p.style.display==='none'?'block':'none';};
$('#mbg').style.display=$('#modal').style.display='block';
}
$('#mx').onclick=$('#mbg').onclick=()=>$('#mbg').style.display=$('#modal').style.display='none';
function reload(){ state.page=1; loadFacets(); loadRows(false); }
$('#more').onclick=$('#more2').onclick=()=>{ state.page++; loadRows(true); };
let t; $('#q').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{state.filters.q=e.target.value.trim();reload();},280);};
$('#density').oninput=e=>{document.documentElement.style.setProperty('--rowh',e.target.value+'px');const c=Math.max(2,Math.round((72-e.target.value)/8));document.documentElement.style.setProperty('--cols',c);LS.set('density',e.target.value);syncURL();};
$('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
// ---- CSV export of the current filtered + sorted view (client-side, capped) ----
const CSV_CAP = 5000; // hard ceiling; if the filtered total exceeds this we export the top N and say so
const CSV_COLS = ['lead_score','issued','address','zip','cd','work_type','building_type','status','project_value','assessed_value','year_built','sqft','use_desc','era','permit_nbr'];
const CSV_HEAD = ['Lead score','Issued','Address','ZIP','CD','Work type','Building type','Status','Project value','Assessed value','Year built','SqFt','Use','Era','Permit #'];
const csvCell = v => { if(v==null) return ''; const s=String(v); return /[",\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s; };
$('#csv').onclick=async()=>{
const btn=$('#csv'), was=btn.textContent;
try{
const want = Math.min(state.total||CSV_CAP, CSV_CAP);
const rows=[]; let page=1;
while(rows.length < want){
btn.textContent='… '+rows.length.toLocaleString();
const d = await (await fetch(api('/api/permits?'+qs({page,limit:200})))).json();
if(!d.rows || !d.rows.length) break;
d.rows.forEach(r=>{ if(r.issued==null) r.issued=r.issue_date; }); // match List/Grid: API sends issue_date
rows.push(...d.rows);
if(d.rows.length < 200) break; // last page
page++;
}
const clip = rows.slice(0, want);
const lines = [CSV_HEAD.join(',')].concat(clip.map(r=>CSV_COLS.map(k=>csvCell(r[k])).join(',')));
const blob = new Blob([''+lines.join('\r\n')], {type:'text/csv;charset=utf-8;'});
const active = Object.entries(state.filters).filter(([,v])=>v).map(([k,v])=>k+'-'+v).join('_').replace(/[^\w-]+/g,'').slice(0,60);
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
a.download = 'la-permits'+(active?'_'+active:'')+'.csv'; document.body.appendChild(a); a.click();
setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
btn.textContent='✓ '+clip.length.toLocaleString();
if((state.total||0) > CSV_CAP) alert('Exported the top '+CSV_CAP.toLocaleString()+' rows (by current sort) of '+state.total.toLocaleString()+' filtered permits. Narrow the filters to capture the rest.');
}catch(e){ btn.textContent='✕ error'; console.error('CSV export failed',e); }
setTimeout(()=>btn.textContent=was,1800);
};
$('#copylink').onclick=async()=>{ // URL already mirrors state via syncURL(); copy the live href
syncURL();
const btn=$('#copylink'), was=btn.textContent, url=location.href;
try{ await navigator.clipboard.writeText(url); }
catch{ const t=document.createElement('textarea'); t.value=url; document.body.appendChild(t); t.select();
try{document.execCommand('copy');}catch{} t.remove(); }
btn.textContent='✓ Copied'; setTimeout(()=>btn.textContent=was,1400);
};
$('#views').querySelectorAll('button').forEach(b=>b.onclick=()=>{
state.view=b.dataset.v; LS.set('view',state.view);
$('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));
loadRows(false);
});
$('#colorby').onchange=e=>{ state.colorBy=e.target.value; LS.set('colorby',state.colorBy); render(); };
// ---- column show/hide popover ----
function renderColsMenu(){
const pop=$('#colspop');
pop.innerHTML = `<div class="hd"><span>COLUMNS</span><span><a id="colall">show all</a> · <a id="colreset">reset</a></span></div>`+
colOrder.map(k=>{ const c=colDef(k); if(!c) return ''; const on=!colHidden.has(k);
return `<label><input type="checkbox" data-k="${k}" ${on?'checked':''}>${escapeHtml(c.label)}</label>`; }).join('');
pop.querySelectorAll('input[type=checkbox]').forEach(cb=>cb.onchange=()=>{
const k=cb.dataset.k;
if(cb.checked) colHidden.delete(k);
else { if(cols().length<=1){ cb.checked=true; return; } colHidden.add(k); } // always keep >=1 column visible
LS.set('hidden',[...colHidden]); renderHead(); renderBody(); syncURL();
});
$('#colall').onclick=()=>{ colHidden.clear(); LS.set('hidden',[]); renderColsMenu(); renderHead(); renderBody(); syncURL(); };
$('#colreset').onclick=()=>{ colOrder=ALLCOLS.map(c=>c.key); colHidden.clear(); LS.set('cols',colOrder); LS.set('hidden',[]); renderColsMenu(); renderHead(); renderBody(); syncURL(); };
}
$('#colsbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#colspop');
if(pop.classList.contains('hidden')){ renderColsMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
document.addEventListener('click',e=>{ if(!e.target.closest('#colswrap')) $('#colspop').classList.add('hidden'); });
// ---- keyboard: "/" focuses search, Esc closes modal/popover ----
document.addEventListener('keydown',e=>{
const tag=(e.target.tagName||'').toLowerCase(), typing=tag==='input'||tag==='select'||tag==='textarea';
if(e.key==='/' && !typing){ e.preventDefault(); $('#q').focus(); $('#q').select(); }
else if(e.key==='Escape'){ $('#mbg').style.display=$('#modal').style.display='none'; $('#colspop').classList.add('hidden'); $('#savedpop').classList.add('hidden'); if(e.target.id==='q') e.target.blur(); }
});
function escapeHtml(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
function escapeAttr(s){return escapeHtml(s).replace(/'/g,''')}
// ---- hydrate the whole view-state from a query string (shared by boot + saved views) ----
// strict=true (saved views): absent params reset to defaults, so a snapshot restores exactly.
// strict=false (boot): absent density/columns fall back to the user's persisted localStorage prefs.
function hydrateFromQuery(qstr, strict){
const u=new URLSearchParams(qstr);
state.filters={};
['q','era','work_type','building_type','status','cd','zip','min_value','since'].forEach(k=>{ if(u.get(k)) state.filters[k]=u.get(k); });
state.view=u.get('view')||'list';
state.colorBy=u.get('color')||'lead_score';
state.sort=u.get('sort')?{col:u.get('sort'), dir:u.get('dir')==='asc'?'asc':'desc'}:{col:'lead_score',dir:'desc'};
if(u.get('cols')){ const vis=u.get('cols').split(',').filter(k=>colDef(k));
if(vis.length){ const rest=ALLCOLS.map(c=>c.key).filter(k=>!vis.includes(k)); colOrder=vis.concat(rest); colHidden=new Set(rest); } }
else if(strict){ colOrder=ALLCOLS.map(c=>c.key); colHidden=new Set(); }
state._density = u.get('d') || (strict ? '34' : LS.get('density','34'));
}
function syncControls(){ // push state back into the visible UI controls
$('#q').value = state.filters.q || '';
const d = state._density || '34';
document.documentElement.style.setProperty('--rowh', d+'px');
document.documentElement.style.setProperty('--cols', Math.max(2,Math.round((72-d)/8)));
$('#density').value = d;
$('#colorby').value = state.colorBy;
$('#views').querySelectorAll('button').forEach(x=>x.classList.toggle('on',x.dataset.v===state.view));
}
// ---- saved views: named snapshots of the full querystring ----
const getSaved = () => LS.get('saved', []);
function applySavedView(q){ hydrateFromQuery('?'+q, true); syncControls(); state.page=1; loadFacets(); loadRows(false); }
function renderSavedMenu(){
const pop=$('#savedpop'), list=getSaved();
pop.innerHTML = `<div class="hd"><span>SAVED VIEWS</span></div>`+
`<div style="padding:2px 8px 8px;display:flex;gap:6px"><input id="savedname" placeholder="Name this view…" style="flex:1;min-width:0;padding:5px 8px"><button id="savedadd" class="loadmore" style="margin:0;padding:5px 11px">Save</button></div>`+
(list.length ? list.map((v,i)=>`<div class="savedrow"><a class="savedapply" data-i="${i}" title="${escapeAttr(v.q)}">${escapeHtml(v.name)}</a><a class="saveddel" data-i="${i}" title="Delete">✕</a></div>`).join('')
: `<div style="padding:6px 8px;color:var(--muted);font-size:12px">No saved views yet — filter, then Save.</div>`);
const save=()=>{ const n=$('#savedname').value.trim(); if(!n) return; syncURL(); const q=location.search.replace(/^\?/,''); const arr=getSaved(); arr.push({name:n,q}); LS.set('saved',arr); renderSavedMenu(); };
$('#savedadd').onclick=save;
$('#savedname').onkeydown=e=>{ if(e.key==='Enter') save(); };
pop.querySelectorAll('.savedapply').forEach(a=>a.onclick=()=>{ const v=getSaved()[+a.dataset.i]; if(v){ applySavedView(v.q); pop.classList.add('hidden'); } });
pop.querySelectorAll('.saveddel').forEach(a=>a.onclick=()=>{ const arr=getSaved(); arr.splice(+a.dataset.i,1); LS.set('saved',arr); renderSavedMenu(); });
}
$('#savedbtn').onclick=e=>{ e.stopPropagation(); const pop=$('#savedpop');
if(pop.classList.contains('hidden')){ renderSavedMenu(); pop.classList.remove('hidden'); } else pop.classList.add('hidden'); };
document.addEventListener('click',e=>{ if(!e.target.closest('#savedwrap')) $('#savedpop').classList.add('hidden'); });
(function(){
hydrateFromQuery(location.search, false); // boot: deep-link params win, else persisted prefs
syncControls();
loadFacets(); loadRows(false);
fetch(api('/api/facets')).then(r=>r.json()).then(d=>{ allFacets=d; renderMapJump(); }).catch(()=>{}); // stable full CD/ZIP lists for the map jump
})();
</script>
</body>
</html>