← back to Commercialrealestate
public/map.html
275 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>Property Map — LA County CRE</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css">
<style>
:root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;}
*{box-sizing:border-box;} html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--fg);font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;}
#map{position:absolute;inset:0;}
.topbar{position:absolute;top:0;left:0;right:0;z-index:1000;display:flex;align-items:center;gap:14px;
padding:9px 14px;background:rgba(10,13,19,.92);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
.topbar b{color:var(--gold);} .topbar .nav{margin-left:auto;display:flex;gap:13px;}
.topbar .nav a{color:var(--muted);text-decoration:none;font-size:13px;} .topbar .nav a:hover{color:var(--gold);}
/* mobile: wrap the topbar so nav links don't clip off-screen */
@media(max-width:620px){ .topbar{flex-wrap:wrap;gap:6px 12px;} .topbar .nav{margin-left:0;flex-wrap:wrap;gap:10px;width:100%;} }
#count{font-size:12px;color:var(--muted);}
.legend{position:absolute;bottom:18px;left:14px;z-index:1000;background:rgba(10,13,19,.92);border:1px solid var(--line);
border-radius:10px;padding:10px 12px;font-size:12px;max-width:200px;backdrop-filter:blur(6px);}
.legend h4{margin:0 0 7px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);}
.legend label{display:flex;align-items:center;gap:7px;cursor:pointer;padding:2px 0;user-select:none;}
.legend .dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;border:1px solid rgba(255,255,255,.3);}
.legend .ct{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums;}
.colorby{display:flex;gap:0;margin:0 0 9px;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
.colorby button{flex:1;background:#0a0d13;border:0;color:var(--muted);font-size:11px;padding:5px 8px;cursor:pointer;}
.colorby button.on{background:rgba(200,162,75,.22);color:#fff;}
.pricekey{margin-top:9px;border-top:1px solid var(--line);padding-top:8px;display:none;}
.pricekey.show{display:block;} .pricekey h4{margin-bottom:6px;}
.pricekey .pr{display:flex;align-items:center;gap:7px;padding:1px 0;}
.clusterdot{border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;
box-shadow:0 0 0 4px rgba(0,0,0,.25);border:1.5px solid rgba(255,255,255,.5);font-size:11px;}
.leaflet-popup-content-wrapper{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:11px;}
.leaflet-popup-tip{background:var(--panel);border:1px solid var(--line);}
.leaflet-popup-content{margin:11px 13px;min-width:230px;}
.pop h3{margin:0 0 3px;font-size:14px;} .pop .ad{color:var(--muted);font-size:12px;}
.pop .row{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0;}
.pop .tag{font-size:11px;background:#0a0d13;border:1px solid var(--line);border-radius:9px;padding:2px 7px;}
.pop .price{color:var(--gold);font-weight:700;}
.pop img{display:block;width:100%;height:150px;object-fit:cover;border-radius:8px;margin:7px 0;background:#0a0d13;border:1px solid var(--line);}
.pop a{color:#5b9bd5;text-decoration:none;font-size:12px;} .pop a:hover{text-decoration:underline;}
.pop .links{display:flex;gap:10px;flex-wrap:wrap;margin-top:5px;}
.aerialcap{font-size:10px;color:var(--muted);margin-top:-3px;}
</style>
</head>
<body>
<div class="topbar">
<b>🗺️ Property Map</b> · LA County <span id="count">loading…</span>
<nav class="nav"><a href="/">Properties</a><a href="/crcp.html">CRCP</a><a href="/deals.html">Deals</a><a href="/rent-rolls.html">Rent Rolls</a><a href="/brokers.html">Mind-map</a><a href="/sales.html">Sales</a><a href="/fha-loans.html">FHA Loans</a></nav>
</div>
<div id="map"></div>
<div class="legend">
<div class="colorby" id="colorby"><button data-m="type" class="on">Color: Type</button><button data-m="status">Status</button><button data-m="price">Price</button><button data-m="score">Score</button></div>
<h4>Property type <span style="font-weight:400;text-transform:none;font-size:10px">(click to filter)</span></h4>
<div id="legend"></div>
<div class="pricekey" id="statuskey"><h4>Residential status</h4></div>
<div class="pricekey" id="pricekey"><h4>Price tier</h4></div>
<div class="pricekey" id="scorekey"><h4>Deal Score</h4></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script src="/deal-score.js"></script>
<script>
// Free stack: Leaflet + OpenStreetMap (streets) + Esri World Imagery (satellite/aerial), US Census geocoding upstream.
const COLORS = {
'Multifamily':'#e6550d','Office':'#3182bd','Retail':'#9b59b6','Industrial':'#7f8c8d',
'Mixed-use':'#27ae60','Land':'#a98246','Condo':'#c8a24b','SFR':'#5b9bd5','Other':'#95a5a6'
};
// Residential lifecycle coloring (Color: Status mode). Commercial points (no rstatus) render grey.
const STATUS_COLORS = { 'Active':'#3fb950','In Escrow':'#d29922','Sold':'#58a6ff','Withdrawn':'#f85149' };
const STATUS_NA = '#6b7280';
function statusColor(p){ return p.rstatus ? (STATUS_COLORS[p.rstatus]||STATUS_NA) : STATUS_NA; }
const map = L.map('map', { preferCanvas:true, zoomControl:true }).setView([34.05,-118.33], 10);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom:19, attribution:'© OpenStreetMap' });
const sat = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ maxZoom:19, attribution:'Esri World Imagery' });
sat.addTo(map); // default to aerial so "from above" is the first impression
L.control.layers({ 'Aerial (Esri)':sat, 'Streets (OSM)':street }, null, { position:'topright' }).addTo(map);
const layers = {}; // type -> L.markerClusterGroup (name kept for legend + gate compat)
const counts = {};
const ALL = []; // {m, p} — every marker + its point, for recoloring
let MODE = 'type'; // 'type' | 'price' | 'score'
const money = n => n==null ? '' : '$'+(n>=1e6 ? (n/1e6).toFixed(n>=1e7?0:2)+'M' : Math.round(n/1e3)+'k');
// Deal Score per point (0-100). Commercial listings join to ranked.json by id (listing-<rid> -> <rid>)
// for cap/DSCR; everything carries its assumable bonus. Computed once into p._score (number|null).
const SCORE_BY_RANKED = {}; // ranked id -> deal score (filled when ranked.json lands)
function scoreFor(p){
if (p._score !== undefined) return p._score; // cached (incl. null)
let s = null;
if (p.kind === 'listing' && p.id){
const rid = String(p.id).replace(/^listing-/, '');
if (SCORE_BY_RANKED[rid] !== undefined) s = SCORE_BY_RANKED[rid];
}
if (s == null && p.assum){ // condos/SFR: assumable-only score (honest: no cap data on these)
const d = DealScore.compute({ assum: p.assum });
s = (d && d.score != null) ? d.score : null;
}
p._score = s; return s;
}
// ---- price-tier coloring (sequential, distinct from the type palette) ----
const PRICE_TIERS = [
{ max:500000, c:'#2dd4bf', label:'< $500k' },
{ max:1000000, c:'#60a5fa', label:'$500k–1M' },
{ max:2000000, c:'#a78bfa', label:'$1M–2M' },
{ max:5000000, c:'#fb923c', label:'$2M–5M' },
{ max:Infinity, c:'#f87171', label:'$5M +' },
];
const PRICE_NA = '#6b7280';
function priceColor(price){ if(price==null||!isFinite(price)||price<=0) return PRICE_NA;
for(const t of PRICE_TIERS) if(price < t.max) return t.c; return '#f87171'; }
function markerColor(p){
if (MODE === 'price') return priceColor(p.price);
if (MODE === 'score') return DealScore.scoreColor(scoreFor(p));
if (MODE === 'status') return statusColor(p);
return COLORS[p.type] || '#888';
}
// cluster bubbles colored by the group's type (per-type groups → one type per cluster)
function clusterIcon(type){ return cluster => { const n=cluster.getChildCount();
const s = n<10?30:n<100?38:n<1000?46:54;
return L.divIcon({ html:`<div class="clusterdot" style="width:${s}px;height:${s}px;background:${COLORS[type]||'#888'}">${n>=1000?(n/1000).toFixed(1)+'k':n}</div>`,
className:'', iconSize:[s,s] }); }; }
// Per-property overhead aerial via the FREE Esri export endpoint (no key). Tight bbox ≈ ~130m around the point.
function aerialUrl(lat,lng){ const d=0.0011;
return `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export`+
`?bbox=${lng-d},${lat-d},${lng+d},${lat+d}&bboxSR=4326&imageSR=4326&size=300,180&format=jpg&f=image`; }
function popupHtml(p){
const ln = `https://www.google.com/search?q=`+encodeURIComponent(`site:loopnet.com "${[p.address,p.city].filter(Boolean).join(' ')}"`);
return `<div class="pop"><h3>${p.address||'(address n/a)'}</h3><div class="ad">${p.city||''} ${p.zip||''}</div>`+
`<div class="row"><span class="tag" style="border-color:${COLORS[p.type]||'#888'}">${p.type}</span>`+
(p.price?`<span class="tag price">${money(p.price)}</span>`:'')+
(p.rstatus?`<span class="tag" style="border-color:${statusColor(p)};color:${statusColor(p)}">${p.rstatus}</span>`:'')+
(p.dom!=null&&isFinite(+p.dom)?`<span class="tag" title="Days on market (Redfin)">${p.dom}d on mkt</span>`:'')+
((s=>s!=null?`<span class="tag" style="border-color:${DealScore.scoreColor(s)};color:${DealScore.scoreColor(s)}" title="Unified Deal Score (0-100): blend of pro-forma rent yield, cap rate, DSCR-readiness, and assumable bonus. A screen, not an appraisal.">⬡ Deal ${s}</span>`:'')(scoreFor(p)))+
(p.assum?`<span class="tag" style="border-color:#34d399;color:#34d399" title="HEURISTIC estimate from property type/era — NOT title-verified. Confirm the recorded deed of trust shows an assumable FHA/VA loan before relying on it.">🔑 Assumable FHA/VA?${p.assum.rate?` · est ~${(+p.assum.rate).toFixed(2)}%`:''}${p.assum.lk==='high'?' (high)':''}</span>`:'')+
`</div>`+
`<img loading="lazy" src="${aerialUrl(p.lat,p.lng)}" alt="aerial view" onerror="this.style.display='none';this.nextElementSibling.textContent='Aerial imagery unavailable for this point';">`+
`<div class="aerialcap">Overhead imagery © Esri World Imagery (free)</div>`+
`<div class="links">`+
`<a href="#" onclick="mapPropHistory('${(p.address||'').replace(/'/g,"\\'")}','${(p.city||'').replace(/'/g,"\\'")}');return false;">📜 History</a>`+
(p.source?`<a href="${p.source}" target="_blank" rel="noopener">View listing ↗</a>`:'')+
`<a href="${ln}" target="_blank" rel="noopener">LoopNet ↗</a>`+
`<a href="https://www.google.com/maps/search/?api=1&query=${p.lat},${p.lng}" target="_blank" rel="noopener">Google Maps ↗</a>`+
`</div></div>`;
}
// Pre-load ranked.json + demographics so listing points get the SAME full Deal Score as the cards
// (pro-forma rent yield + cap + DSCR + proxy DSCR + assumable). Failure-safe: if either misses, the
// map still draws (listings just fall back to assumable-only / unscored — never blocks, never NaN).
const rankedReady = Promise.all([
fetch('/data/ranked.json').then(r=>r.ok?r.json():{ranked:[]}).catch(()=>({ranked:[]})),
fetch('/data/demographics.json').then(r=>r.ok?r.json():{byCity:{}}).catch(()=>({byCity:{}})),
]).then(([rk, demo])=>{
const DEMO = (demo && demo.byCity) || {};
const SCEN = { downPct:25, ratePct:6.75, amortYears:30 }; // standard underwriting for the proxy DSCR
const debtSvc = (loan)=>{ if(!(loan>0)) return 0; const r=SCEN.ratePct/100/12, n=SCEN.amortYears*12;
if(r===0) return loan/SCEN.amortYears; return loan*(r*Math.pow(1+r,n))/(Math.pow(1+r,n)-1)*12; };
for (const p of (rk.ranked||[])){
const d = DEMO[(p.city||'').toLowerCase()];
let grossYieldPro = null, proPerYear = null;
if (d && d.median_gross_rent && p.price>0){
const pro = Math.round(d.median_gross_rent*(p.units||1)*1.12);
proPerYear = pro*12; grossYieldPro = +(100*proPerYear/p.price).toFixed(2);
}
let dscr = p.finance ? p.finance.dscr : null, proxyDscr = null;
if (p.cap_rate==null && proPerYear && p.price>0){
const loan = p.price*(1-SCEN.downPct/100); const ads = debtSvc(loan);
if (ads>0) proxyDscr = +((proPerYear*0.6)/ads).toFixed(2);
}
const ds = DealScore.compute({ grossYieldPro, cap_rate:p.cap_rate, verified:p.verified, dscr, proxyDscr, assum:p.assum, fhaApproved:p.fhaApproved });
SCORE_BY_RANKED[p.id] = (ds && ds.score!=null) ? ds.score : null;
}
}).catch(()=>{});
Promise.all([fetch('/data/map-points.json').then(r=>r.ok?r.json():Promise.reject(r.status)), rankedReady])
.then(([data]) => {
const pts = data.points||[];
for (const p of pts){
const type = p.type||'Other';
if (!layers[type]){ layers[type]=L.markerClusterGroup({ chunkedLoading:true, maxClusterRadius:55,
disableClusteringAtZoom:16, spiderfyOnMaxZoom:false, iconCreateFunction:clusterIcon(type) }).addTo(map); counts[type]=0; }
counts[type]++;
const m = L.circleMarker([p.lat,p.lng], { radius:type==='SFR'?4:5,
color:markerColor(p), weight:1, fillColor:markerColor(p), fillOpacity:.78 })
.bindPopup(()=>popupHtml(p), { maxWidth:280 });
ALL.push({ m, p });
layers[type].addLayer(m);
}
document.getElementById('count').textContent = `· ${pts.length.toLocaleString()} placed`;
buildLegend(); wireColorBy();
}).catch(e => { document.getElementById('count').textContent = '· map data not ready (run scripts/geocode-census.js)'; });
// recolor every marker for the active mode (type ↔ price). Clusters stay type-colored (density indicator).
function recolor(){ for (const { m, p } of ALL) m.setStyle({ color:markerColor(p), fillColor:markerColor(p) }); }
function wireColorBy(){
const key = document.getElementById('pricekey');
key.innerHTML = '<h4>Price tier</h4>' + PRICE_TIERS.map(t=>`<div class="pr"><span class="dot" style="background:${t.c}"></span>${t.label}</div>`).join('')
+ `<div class="pr"><span class="dot" style="background:${PRICE_NA}"></span>price n/a</div>`;
const skey = document.getElementById('scorekey');
skey.innerHTML = '<h4>Deal Score</h4>' + DealScore.TIERS.map(t=>`<div class="pr"><span class="dot" style="background:${t.c}"></span>${t.label}</div>`).join('')
+ `<div class="pr"><span class="dot" style="background:${DealScore.SCORE_NA}"></span>not scored</div>`
+ `<div class="pr" style="font-size:10px;color:var(--muted);margin-top:4px">Commercial = rent+cap+DSCR; condos = assumable-only.</div>`;
const stkey = document.getElementById('statuskey');
stkey.innerHTML = '<h4>Residential status</h4>' + Object.entries(STATUS_COLORS).map(([l,c])=>`<div class="pr"><span class="dot" style="background:${c}"></span>${l}</div>`).join('')
+ `<div class="pr"><span class="dot" style="background:${STATUS_NA}"></span>commercial / n/a</div>`;
document.querySelectorAll('#colorby button').forEach(b => b.addEventListener('click', () => {
MODE = b.dataset.m;
document.querySelectorAll('#colorby button').forEach(x=>x.classList.toggle('on', x===b));
key.classList.toggle('show', MODE==='price');
skey.classList.toggle('show', MODE==='score');
stkey.classList.toggle('show', MODE==='status');
recolor();
}));
}
function buildLegend(){
const el = document.getElementById('legend');
el.innerHTML = Object.keys(COLORS).filter(t=>counts[t]).map(t =>
`<label><span class="dot" style="background:${COLORS[t]}"></span>`+
`<input type="checkbox" checked data-t="${t}" style="display:none"><span>${t}</span>`+
`<span class="ct">${counts[t].toLocaleString()}</span></label>`).join('');
el.querySelectorAll('label').forEach(lab => lab.addEventListener('click', e => {
e.preventDefault(); const cb=lab.querySelector('input'), t=cb.dataset.t;
cb.checked=!cb.checked; lab.style.opacity=cb.checked?'1':'.4';
if (cb.checked) map.addLayer(layers[t]); else map.removeLayer(layers[t]);
}));
}
</script>
<!-- 100-yr property-history popup (address-only, no names) — reuses /api/property-history -->
<div id="phov"><div id="phmodal"><button id="phx" aria-label="Close">✕</button><div id="phbody"></div></div></div>
<style>
#phov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
#phov.on{display:flex;}
#phmodal{width:min(640px,96vw);background:#161b22;border:1px solid #2a313c;border-radius:15px;padding:20px 22px;position:relative;color:#e6edf3;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
#phmodal h3{margin:0 0 2px;font-size:19px;} #phmodal .s{color:#d29922;font-size:13px;}
#phx{position:absolute;top:12px;right:14px;background:none;border:0;color:#8b949e;font-size:22px;cursor:pointer;}
#phmodal .ci{display:flex;gap:14px;flex-wrap:wrap;margin:8px 0;font-size:13.5px;}
#phmodal .sec{margin-top:14px;} #phmodal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b949e;}
#phmodal .it{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #20262f;font-size:13px;} #phmodal .m{color:#8b949e;}
</style>
<script>
(function(){
var esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<');
var mny=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
document.getElementById('phx').onclick=()=>document.getElementById('phov').classList.remove('on');
document.getElementById('phov').addEventListener('click',e=>{ if(e.target.id==='phov') e.currentTarget.classList.remove('on'); });
window.mapPropHistory=async function(address,city){
var ov=document.getElementById('phov'), mb=document.getElementById('phbody'); ov.classList.add('on'); mb.innerHTML='<div class="m">loading history for '+esc(address)+'…</div>';
var d; try{ d=await (await fetch('/api/property-history?address='+encodeURIComponent(address)+'&city='+encodeURIComponent(city||''))).json(); }catch(e){ mb.innerHTML='<div class="m">error</div>'; return; }
var a=d.assessor||d.assessorArchive;
var h='<h3>'+esc(address)+'</h3><div class="s">'+esc(city||'')+' · property history (address only — no names)</div>';
if(a){ h+='<div class="sec"><h4>Assessor parcel'+(a.roll_year?' ('+esc(a.roll_year)+' roll)':'')+'</h4><div class="ci">'
+(a.year_built?'<span>🏗 Built '+a.year_built+'</span>':'')+(a.bedrooms!=null?'<span>🛏 '+a.bedrooms+' bd</span>':'')+(a.bathrooms!=null?'<span>🛁 '+a.bathrooms+' ba</span>':'')
+(a.sqft_main?'<span>📐 '+Number(a.sqft_main).toLocaleString()+' sqft</span>':'')+(a.units?'<span>🏠 '+a.units+' units</span>':'')
+((a.use_desc2||a.use_desc1||a.use_description)?'<span>🏷 '+esc(a.use_desc2||a.use_desc1||a.use_description)+'</span>':'')
+((a.roll_total_value||a.total_value)?'<span>💰 assessed '+mny(a.roll_total_value||a.total_value)+'</span>':'')+'</div></div>'; }
h+='<div class="sec"><h4>Sold history ('+d.sales.length+')</h4>'+(d.sales.length?d.sales.map(x=>'<div class="it"><span>'+esc(x.address)+(x.city?', '+esc(x.city):'')+'</span><span class="m">'+mny(x.sold_price)+' · '+String(x.sold_date||'').slice(0,10)+(x.mls?' · MLS '+esc(x.mls):'')+'</span></div>').join(''):'<div class="m">no sold records on file</div>')+'</div>';
if(d.permits&&d.permits.length) h+='<div class="sec"><h4>Permits ('+d.permits.length+')</h4>'+d.permits.map(p=>'<div class="it"><span>'+esc(p.permit_type||p.use_description||'permit')+'</span><span class="m">'+esc(String(p.issue_date||'').slice(0,10))+(p.valuation?' · '+mny(p.valuation):'')+'</span></div>').join('')+'</div>';
if(!a && !d.sales.length && !(d.permits||[]).length) h+='<div class="m">No history on file for this address yet.</div>';
mb.innerHTML=h;
};
})();
</script>
</body>
</html>