← back to Socrata Cre Viewer
public/index.html
121 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LA Socrata CRE Prospects</title>
<style>
:root{--bg:#0e1116;--panel:#161b22;--line:#232b36;--ink:#e6edf3;--dim:#8b98a8;--accent:#c9793a;--chip:#1d2530;--rowh:34px;--fs:13px}
*{box-sizing:border-box}
body{margin:0;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink)}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
header h1{font-size:16px;margin:0;font-weight:650}
header .sub{color:var(--dim);font-size:12px}
.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
input,select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:13px}
input#q{min-width:300px}
.bar label{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-right:-4px}
.density{display:flex;align-items:center;gap:7px;margin-left:auto}
.density input[type=range]{accent-color:var(--accent)}
.count{color:var(--dim);font-size:12px;white-space:nowrap}.count b{color:var(--ink)}
.wrap{overflow:auto;height:calc(100vh - 118px)}
table{border-collapse:collapse;width:100%;font-size:var(--fs)}
thead th{position:sticky;top:0;background:var(--panel);border-bottom:1px solid var(--line);text-align:left;padding:8px 10px;white-space:nowrap;cursor:pointer;user-select:none;font-weight:600;color:#cdd6e0}
thead th .ar{color:var(--accent);font-size:10px;margin-left:3px}
tbody td{border-bottom:1px solid var(--line);padding:0 10px;height:var(--rowh);white-space:nowrap;max-width:320px;overflow:hidden;text-overflow:ellipsis}
tbody tr:hover{background:#131922}
.role{display:inline-block;padding:1px 7px;border-radius:10px;background:var(--chip);color:#e0b48a;font-size:11px}
.src{font-size:11px;color:var(--dim)}
.num{text-align:right;font-variant-numeric:tabular-nums}
.when{color:var(--dim);font-size:11px}.muted{color:var(--dim)}
.pager{display:flex;gap:8px;align-items:center;padding:9px 18px;border-top:1px solid var(--line);background:var(--bg)}
.pager button{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 12px;cursor:pointer}
.pager button:disabled{opacity:.4;cursor:default}
.empty{padding:40px;text-align:center;color:var(--dim)}
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
<h1>LA Socrata CRE Prospects</h1>
<span class="sub">Net-new firms from LA Active Businesses + LADBS permits · deduped vs the curated LABJ set</span>
</header>
<div class="bar">
<input id="q" placeholder="Search firm, contact, address, NAICS, work…" autocomplete="off">
<label>Role</label><select id="role"></select>
<label>Source</label><select id="source"></select>
<label>City</label><select id="city"></select>
<label>NAICS</label><select id="naics_desc"></select>
<span class="count" id="count">…</span>
<div class="density"><span class="muted" style="font-size:11px">Density</span><input type="range" id="density" min="0" max="4" step="1" value="1"></div>
</div>
<div class="wrap"><table><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table><div class="empty" id="empty" hidden>No matches.</div></div>
<div class="pager">
<button id="prev">‹ Prev</button>
<span class="count">Page <b id="page">1</b> <span class="muted">of</span> <b id="pages">1</b></span>
<button id="next">Next ›</button><span class="muted" id="range"></span>
</div>
<script>
const LS='socrata-cre-view';
const COLS=[['firm_name','Firm'],['role','Role'],['contact_name','Contact'],['city','City'],['naics_desc','NAICS'],['permit_sub_type','Permit'],['valuation','Valuation'],['issue_date','Date'],['source','Source'],['first_seen','First seen']];
const DENS=[['26px','12px'],['34px','13px'],['40px','14px'],['48px','15px'],['58px','16px']];
const state=Object.assign({q:'',role:'',source:'',city:'',naics_desc:'',sort:'firm_name',dir:'asc',page:1,limit:50,density:1},JSON.parse(localStorage.getItem(LS)||'{}'));
const save=()=>localStorage.setItem(LS,JSON.stringify(state));const $=id=>document.getElementById(id);
function dens(){const[h,f]=DENS[state.density]||DENS[1];document.documentElement.style.setProperty('--rowh',h);document.documentElement.style.setProperty('--fs',f);$('density').value=state.density;}
function esc(s){return(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function fmt(s){if(!s)return'';try{return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});}catch{return s;}}
function money(v){if(!v)return'<span class=muted>—</span>';const n=+String(v).replace(/[^0-9.]/g,'');return isNaN(n)||!n?'<span class=muted>—</span>':'$'+n.toLocaleString();}
function head(){$('head').innerHTML=COLS.map(([k,l])=>`<th data-k="${k}">${l}${state.sort===k?`<span class="ar">${state.dir==='asc'?'▲':'▼'}</span>`:''}</th>`).join('');
$('head').querySelectorAll('th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;if(state.sort===k)state.dir=state.dir==='asc'?'desc':'asc';else{state.sort=k;state.dir='asc';}state.page=1;save();load();});}
function rows(d){$('empty').hidden=d.rows.length>0;$('rows').innerHTML=d.rows.map(r=>`<tr>
<td title="${esc(r.firm_name)}"><b>${esc(r.firm_name)}</b></td>
<td><span class="role">${esc(r.role)}</span></td>
<td>${esc(r.contact_name)||'<span class=muted>—</span>'}</td>
<td>${esc(r.city)}</td>
<td class="muted" title="${esc(r.naics_desc)}">${esc(r.naics_desc)||'—'}</td>
<td class="muted">${esc(r.permit_sub_type)||'—'}</td>
<td class="num">${money(r.valuation)}</td>
<td class="when">${fmt(r.issue_date)}</td>
<td class="src">${esc(r.source)}</td>
<td class="when" title="${esc(r.first_seen)}">${fmt(r.first_seen)}</td></tr>`).join('');}
async function load(){const p=new URLSearchParams();for(const k of['q','role','source','city','naics_desc','sort','dir','page','limit'])if(state[k]!==''&&state[k]!=null)p.set(k,state[k]);
const d=await(await fetch('/api/rows?'+p)).json();head();rows(d);
const pages=Math.max(1,Math.ceil(d.total/d.limit)),start=d.total?(d.page-1)*d.limit+1:0,end=Math.min(d.page*d.limit,d.total);
$('count').innerHTML=`<b>${d.total.toLocaleString()}</b> firms`;$('page').textContent=d.page;$('pages').textContent=pages.toLocaleString();
$('range').textContent=d.total?`showing ${start.toLocaleString()}–${end.toLocaleString()}`:'';$('prev').disabled=d.page<=1;$('next').disabled=d.page>=pages;}
function fill(id,list,label){const cur=state[id];$(id).innerHTML=`<option value="">All ${label}</option>`+list.map(r=>`<option value="${esc(r.v)}">${esc(r.v)} (${r.n.toLocaleString()})</option>`).join('');$(id).value=cur;$(id).onchange=()=>{state[id]=$(id).value;state.page=1;save();load();};}
async function boot(){dens();$('q').value=state.q;const f=await(await fetch('/api/facets')).json();
fill('role',f.role,'roles');fill('source',f.source,'sources');fill('city',f.city,'cities');fill('naics_desc',f.naics_desc,'NAICS');
let t;$('q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{state.q=$('q').value.trim();state.page=1;save();load();},250);};
$('density').oninput=()=>{state.density=+$('density').value;save();dens();};
$('prev').onclick=()=>{if(state.page>1){state.page--;save();load();}};$('next').onclick=()=>{state.page++;save();load();};load();}
boot();
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>