← 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,'&amp;').replace(/</g,'&lt;');
  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>