← back to Commercialrealestate

public/graphics.html

250 lines

<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='dark'?'dark':'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
     whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<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='%230e1116'/%3E%3Ctext x='8' y='12' font-size='10' text-anchor='middle' fill='%233fb950'%3E%F0%9F%93%8A%3C/text%3E%3C/svg%3E">
<title>CRE Graphics — Brokers, Firms & Warrantability · LA County</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
  *{box-sizing:border-box;} html,body{margin:0;}
  body{background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
  header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--headbg);backdrop-filter:blur(8px);z-index:5;}
  header h1{font-size:18px;margin:0;} header a{color:var(--blue);text-decoration:none;font-size:13px;}
  header .nav{margin-left:auto;display:flex;gap:18px;}
  .stats{display:flex;gap:18px;font-size:12px;color:var(--mut);padding:10px 22px;flex-wrap:wrap;border-bottom:1px solid var(--line);}
  .stats b{color:var(--ink);font-size:16px;}
  main{padding:32px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:26px;max-width:1400px;margin:0 auto;}
  .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:24px;min-height:340px;display:flex;flex-direction:column;}
  .card.wide{grid-column:1 / -1;}
  .card h2{font-size:14px;margin:0 0 4px;}
  .card .sub{font-size:12px;color:var(--mut);margin:0 0 14px;}
  .chartwrap{position:relative;flex:1;min-height:260px;}
  .note{font-size:11px;color:var(--gold);margin-top:12px;padding:8px 10px;background:rgba(210,153,34,.08);border:1px solid rgba(210,153,34,.25);border-radius:8px;}
  table{width:100%;border-collapse:collapse;font-size:12px;}
  th,td{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line);}
  th{color:var(--mut);font-weight:600;text-transform:uppercase;letter-spacing:.4px;font-size:10px;}
  td.r,th.r{text-align:right;font-variant-numeric:tabular-nums;}
  .pill{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600;}
  .pill.soon{background:rgba(248,81,73,.15);color:var(--red);}
  .pill.ok{background:rgba(63,185,80,.15);color:var(--acc);}
  /* every datum is drillable (Steve rule: no dead-end text) */
  .stats a.statlink{color:var(--mut);text-decoration:none;cursor:pointer;}
  .stats a.statlink:hover b{color:var(--blue);}
  a.dl{color:var(--blue);text-decoration:none;}
  td a.dl{color:var(--ink);border-bottom:1px dotted rgba(88,166,255,.45);}
  td a.dl:hover{color:var(--blue);border-bottom-color:var(--blue);}
  #expTable tbody tr:hover td{background:rgba(88,166,255,.05);}
  .chartwrap canvas{cursor:default;}
  @media(max-width:820px){ main{grid-template-columns:1fr;} }
</style>
<link rel="stylesheet" href="/crcp-theme.css">
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
  <h1>📊 CRE Graphics</h1>
  <div class="nav">
    <a href="/">← properties</a>
    <a href="/brokers.html">mind-map</a>
    <a href="/condos.html">condos →</a>
  </div>
</header>
<div class="stats" id="stats"><span>loading…</span></div>

<main>
  <div class="card">
    <h2>Top brokers by listings</h2>
    <p class="sub">Lead/co listing count in our LA County set (cre DB broker graph).</p>
    <div class="chartwrap"><canvas id="brokerChart"></canvas></div>
  </div>

  <div class="card">
    <h2>Firm market share</h2>
    <p class="sub">Distinct listings represented per brokerage (broker → firm → listing).</p>
    <div class="chartwrap"><canvas id="firmChart"></canvas></div>
  </div>

  <div class="card">
    <h2>Real LA condos-for-sale by warrantability</h2>
    <p class="sub">The actual condo listings we scraped (Redfin feed-first), classified against the FHA list.</p>
    <div class="chartwrap"><canvas id="listingWarrantChart"></canvas></div>
    <div class="note" id="listingWarrantLabel"></div>
  </div>

  <div class="card">
    <h2>FHA reference-list breakdown</h2>
    <p class="sub">All LA County condo projects on the HUD FHA-approved list, by approval status.</p>
    <div class="chartwrap"><canvas id="warrantChart"></canvas></div>
    <div class="note" id="warrantLabel"></div>
  </div>

  <div class="card wide">
    <h2>Scraped condo listings by city</h2>
    <p class="sub">Where the real for-sale condo inventory we captured concentrates (green = FHA-approved proxy share).</p>
    <div class="chartwrap"><canvas id="listingCityChart"></canvas></div>
  </div>

  <div class="card">
    <h2>FHA-approved condos by city</h2>
    <p class="sub">Currently-approved projects — where warrantable (FHA-proxy) inventory concentrates.</p>
    <div class="chartwrap"><canvas id="cityChart"></canvas></div>
  </div>

  <div class="card wide" id="expCard">
    <h2>FHA approval expiring within 12 months ⚠️</h2>
    <p class="sub">Approved projects whose FHA certification lapses soon — a buyer using FHA financing should confirm re-certification. (Proxy signal, not lender-verified.)</p>
    <div style="overflow:auto;max-height:340px">
      <table id="expTable"><thead><tr><th>Project</th><th>City</th><th>Zip</th><th class="r">Expires</th><th class="r">Days</th></tr></thead><tbody></tbody></table>
    </div>
  </div>
</main>

<script>
// Day/Night theme — pre-applied before first paint (see head snippet).
// apply() only PAINTS — does NOT persist (passive loads must not lock in a theme;
// only an explicit toggle click below saves a pref, so everyone gets the Day default).
(function(){
  var root=document.documentElement;
  function applyTheme(t){ root.setAttribute('data-theme',t); }
  var cur; try{ cur=localStorage.getItem('crcp-theme'); }catch(e){}
  applyTheme(cur==='dark'?'dark':'light');
  // Wire up the toggle button injected by crcp-theme.js (defer — may not exist yet;
  // observe the header for it and intercept its click to persist crcp-theme).
  function wireToggle(){
    var btn=document.getElementById('crcpThemeToggle'); if(!btn||btn.__wired2) return;
    btn.__wired2=true;
    btn.addEventListener('click',function(e){
      e.stopImmediatePropagation();   // suppress crcp-theme.js's own handler so we don't double-toggle
      var nt=root.getAttribute('data-theme')==='light'?'dark':'light';
      applyTheme(nt); try{ localStorage.setItem('crcp-theme',nt); }catch(e){}
    },true);   // capture phase — fires before crcp-theme.js's own click handler
  }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',wireToggle); else wireToggle();
  // also wire after defer scripts finish (crcp-theme.js injects the button on DOMContentLoaded)
  document.addEventListener('DOMContentLoaded',wireToggle);
})();
</script>
<script>
const $=s=>document.querySelector(s);
const GH={ ink:'#e6edf3', mut:'#8b949e', line:'#2a313c', acc:'#3fb950', blue:'#58a6ff', gold:'#d29922', red:'#f85149' };
// Read CSS token for doughnut slice-gap — adapts to day/night theme.
const CARD_BG=getComputedStyle(document.documentElement).getPropertyValue('--card').trim()||'#1c232e';
Chart.defaults.color=GH.mut; Chart.defaults.borderColor=GH.line; Chart.defaults.font.family='-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif';
const PALETTE=['#58a6ff','#3fb950','#d29922','#bc8cff','#f85149','#39c5cf','#db61a2','#7ee787','#ffa657','#a5d6ff','#ff7b72','#56d364'];

async function j(u){ const r=await fetch(u); return r.json(); }
// --- drill helpers (every datum hrefs to its filtered/detail view) ---
const enc=encodeURIComponent;
const go=u=>{ if(u) location.href=u; };
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// flip the canvas cursor to a pointer while hovering a clickable element
const HOVER=(evt,els)=>{ const t=evt&&evt.native&&evt.native.target; if(t) t.style.cursor=els.length?'pointer':'default'; };

(async()=>{
  // --- top brokers ---
  const brokersRaw = await j('/api/brokers/top?limit=12');
  const brokers = Array.isArray(brokersRaw) ? brokersRaw
    : Array.isArray(brokersRaw&&brokersRaw.brokers) ? brokersRaw.brokers
    : Array.isArray(brokersRaw&&brokersRaw.topBrokers) ? brokersRaw.topBrokers : [];
  new Chart($('#brokerChart'),{ type:'bar', data:{
    labels: brokers.map(b=>b.name),
    datasets:[{ label:'listings', data:brokers.map(b=>b.listings), backgroundColor:GH.acc }] },
    options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
      onHover:HOVER, onClick:(e,els)=>{ if(els.length){ const b=brokers[els[0].index]; if(b&&b.id!=null) go('/crcp.html?broker='+enc(b.id)+'&name='+enc(b.name||'')); } },
      plugins:{ legend:{display:false}, tooltip:{ callbacks:{ afterLabel:(c)=>brokers[c.dataIndex].firm||'' } } },
      scales:{ x:{ grid:{color:GH.line} }, y:{ grid:{display:false} } } } });

  // --- firm market share (doughnut) ---
  const firmsRaw = await j('/api/firms/share?limit=12');
  const firms = Array.isArray(firmsRaw) ? firmsRaw
    : Array.isArray(firmsRaw&&firmsRaw.firms) ? firmsRaw.firms : [];
  new Chart($('#firmChart'),{ type:'doughnut', data:{
    labels: firms.map(f=>f.firm),
    datasets:[{ data:firms.map(f=>f.listings), backgroundColor:PALETTE, borderColor:CARD_BG, borderWidth:2 }] },
    options:{ responsive:true, maintainAspectRatio:false, cutout:'52%',
      onHover:HOVER, onClick:(e,els)=>{ if(els.length){ const f=firms[els[0].index]; if(f) go('/crcp.html?firm='+enc(f.firm||'')); } },
      plugins:{ legend:{ position:'right', labels:{ boxWidth:10, font:{size:11} } },
        tooltip:{ callbacks:{ label:(c)=>`${c.label}: ${c.raw} listings (${firms[c.dataIndex].brokers} brokers)` } } } } });

  // --- REAL scraped condo listings by warrantability (the actual for-sale inventory) ---
  const cs = await j('/api/condos/stats');
  const lwLabels = { fha_approved:'FHA-approved (proxy: warrantable)', fha_expired:'FHA-expired (re-cert needed)', not_listed:'Not on FHA list (verify w/ lender)', heuristic_flag:'Flagged non-warrantable' };
  const lwKeys = Object.keys(cs.byStatus||{});
  $('#listingWarrantLabel').textContent = '⚖️ ' + (cs.label||'') + '  ·  ' + (cs.total||0) + ' real listings · ' + (cs.source||'');
  new Chart($('#listingWarrantChart'),{ type:'doughnut', data:{
    labels: lwKeys.map(k=>lwLabels[k]||k),
    datasets:[{ data:lwKeys.map(k=>cs.byStatus[k]), backgroundColor:lwKeys.map(k=>k==='fha_approved'?GH.acc:k==='fha_expired'?GH.gold:k==='heuristic_flag'?GH.red:GH.mut), borderColor:CARD_BG, borderWidth:2 }] },
    options:{ responsive:true, maintainAspectRatio:false, cutout:'55%',
      onHover:HOVER, onClick:(e,els)=>{ if(els.length){ go('/condos.html?status='+enc(lwKeys[els[0].index])); } },
      plugins:{ legend:{ position:'bottom', labels:{ boxWidth:11, font:{size:11} } } } } });

  // --- scraped listings by city (stacked: approved vs other) ---
  const lc = cs.byCity||[];
  new Chart($('#listingCityChart'),{ type:'bar', data:{
    labels: lc.map(c=>c.city),
    datasets:[
      { label:'FHA-approved (proxy)', data:lc.map(c=>c.approved), backgroundColor:GH.acc, stack:'s' },
      { label:'other / not-listed', data:lc.map(c=>c.n-c.approved), backgroundColor:GH.mut, stack:'s' } ] },
    options:{ responsive:true, maintainAspectRatio:false,
      onHover:HOVER, onClick:(e,els)=>{ if(els.length){ const el=els[0], c=lc[el.index]; if(c) go('/condos.html?city='+enc(c.city)+(el.datasetIndex===0?'&status=fha_approved':'')); } },
      plugins:{ legend:{ position:'bottom', labels:{ boxWidth:11, font:{size:11} } } },
      scales:{ x:{ stacked:true, grid:{display:false} }, y:{ stacked:true, grid:{color:GH.line} } } } });

  // --- warrantability (FHA reference list) ---
  const w = await j('/api/warrantability');
  $('#warrantLabel').textContent = '⚖️ ' + (w.meta.label || '');
  const wlabels={ fha_approved:'FHA-approved (proxy: warrantable)', fha_expired:'FHA-expired (re-cert needed)', fha_other:'Other' };
  const wkeys=Object.keys(w.byStatus);
  new Chart($('#warrantChart'),{ type:'doughnut', data:{
    labels: wkeys.map(k=>wlabels[k]||k),
    datasets:[{ data:wkeys.map(k=>w.byStatus[k]), backgroundColor:wkeys.map(k=>k==='fha_approved'?GH.acc:k==='fha_expired'?GH.gold:GH.mut), borderColor:CARD_BG, borderWidth:2 }] },
    options:{ responsive:true, maintainAspectRatio:false, cutout:'55%',
      onHover:HOVER, onClick:(e,els)=>{ if(els.length){ const k=wkeys[els[0].index]; go('/condos.html?status='+enc(k==='fha_other'?'all':k)); } },
      plugins:{ legend:{ position:'bottom', labels:{ boxWidth:11, font:{size:11} } } } } });

  // --- approved by city ---
  new Chart($('#cityChart'),{ type:'bar', data:{
    labels: w.cities.map(c=>c.city),
    datasets:[{ label:'approved projects', data:w.cities.map(c=>c.n), backgroundColor:GH.blue }] },
    options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
      onHover:HOVER, onClick:(e,els)=>{ if(els.length){ const c=w.cities[els[0].index]; if(c) go('/condos.html?status=fha_approved&city='+enc(c.city)); } },
      plugins:{ legend:{display:false} }, scales:{ x:{grid:{color:GH.line}}, y:{grid:{display:false}} } } });

  // --- expiring table ---
  // This card = APPROVED projects lapsing within 12mo, so drill to the matching pre-filter
  // (?status=expiring365), NOT fha_expired (already-lapsed) which was the wrong prior target.
  const EXP='/condos.html?status=expiring365&sort=expiry_soon';
  const A=(href,txt)=>`<a class="dl" href="${href}">${txt}</a>`;
  $('#expTable tbody').innerHTML = (w.expiring||[]).map(e=>`
    <tr><td>${A(EXP+'&q='+enc(e.project||''), esc(e.project))}</td>
        <td>${e.city?A('/condos.html?city='+enc(e.city), esc(e.city)):'—'}</td>
        <td>${e.zip?A('/condos.html?q='+enc(e.zip), esc(e.zip)):''}</td>
        <td class="r">${A(EXP, esc(e.expiration))}</td>
        <td class="r">${A(EXP, '<span class="pill '+(e.days<90?'soon':'ok')+'">'+e.days+'d</span>')}</td></tr>`).join('')
    || '<tr><td colspan="5" style="color:var(--mut)">none expiring within 12 months</td></tr>';

  // --- stats bar ---
  const approved=w.byStatus.fha_approved||0, expired=w.byStatus.fha_expired||0;
  $('#stats').innerHTML =
    `<a class="statlink" href="/crcp.html"><b>${brokers.length?'1,256':'—'}</b> brokers tracked</a>`+
    `<a class="statlink" href="/crcp.html"><b>${firms.length}</b> firms charted</a>`+
    `<a class="statlink" href="/condos.html"><b>${(cs.total||0).toLocaleString()}</b> real condos scraped</a>`+
    `<a class="statlink" href="/condos.html?status=fha_approved"><b>${cs.byStatus&&cs.byStatus.fha_approved||0}</b> listings FHA-approved (proxy)</a>`+
    `<a class="statlink" href="/condos.html?status=fha_approved"><b>${approved}</b> FHA-approved condo projects</a>`+
    `<a class="statlink" href="/condos.html?status=fha_expired"><b>${expired}</b> FHA-expired</a>`+
    `<a class="statlink" href="/condos.html?status=expiring365&sort=expiry_soon"><b>${(w.expiring||[]).length}</b> approvals lapsing &lt;12mo</a>`+
    `<span style="color:var(--mut)">source: HUD FHA list · cre broker graph · <em>click any bar, slice, row or number to drill in</em></span>`;
})().catch(e=>{ $('#stats').innerHTML='<span style="color:var(--red)">load error: '+e.message+'</span>'; });
</script>
  <script src="/col-resize.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>