← back to Commercialrealestate

public/brokers.html

139 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>Broker Mind-Map — LA County CRE</title>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<style>
  :root{ --bg:#0e1116; --card:#161b22; --line:#2a313c; --ink:#e6edf3; --mut:#8b949e; --acc:#3fb950; --blue:#58a6ff; --gold:#d29922; }
  *{box-sizing:border-box;} html,body{height:100%;}
  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;height:100vh;}
  header{display:flex;align-items:center;gap:16px;padding:12px 20px;border-bottom:1px solid var(--line);flex:0 0 auto;}
  header h1{font-size:17px;margin:0;} header a{color:var(--blue);text-decoration:none;font-size:13px;}
  .stats{display:flex;gap:16px;margin-left:auto;font-size:12px;color:var(--mut);}
  .stats b{color:var(--ink);font-size:15px;}
  .wrap{flex:1 1 auto;display:flex;min-height:0;}
  #net{flex:1;min-width:0;background:radial-gradient(circle at 50% 40%,#11161d,#0b0e13);}
  aside{width:300px;flex:0 0 auto;border-left:1px solid var(--line);background:#0b0e13;overflow-y:auto;padding:14px;}
  aside h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:0 0 8px;}
  .ctrl{margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--line);}
  input[type=text],input[type=range]{width:100%;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:13px;}
  input[type=range]{padding:0;accent-color:var(--blue);}
  .legend span{display:inline-flex;align-items:center;gap:5px;margin-right:12px;font-size:12px;color:var(--mut);}
  .dot{width:10px;height:10px;border-radius:50%;display:inline-block;}
  .blist .row{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-top:1px solid var(--line);cursor:pointer;font-size:13px;}
  .blist .row:hover{color:var(--blue);} .blist .firm{color:var(--mut);font-size:11px;}
  .blist .n{color:var(--acc);font-variant-numeric:tabular-nums;}
  #detail{font-size:13px;} #detail .k{color:var(--mut);}
  .empty{padding:40px;color:var(--mut);text-align:center;}
</style>
</head>
<body>
<header>
  <h1>🕸️ Broker Mind-Map</h1>
  <a href="/">← back to properties</a>
  <div class="stats" id="stats"></div>
</header>
<div class="wrap">
  <div id="net"></div>
  <aside>
    <div class="ctrl">
      <h3>Filter</h3>
      <input type="text" id="firmq" placeholder="highlight firm / broker…">
      <div style="margin-top:10px;font-size:12px;color:var(--mut)">Min listings in our set: <b id="mlval">1</b></div>
      <input type="range" id="minl" min="1" max="10" step="1" value="1">
      <div class="legend" style="margin-top:10px"><span><i class="dot" style="background:#58a6ff"></i>firm</span><span><i class="dot" style="background:#3fb950"></i>broker</span><span><i class="dot" style="background:#d29922"></i>co-list</span></div>
    </div>
    <div class="ctrl" id="detailwrap" style="display:none"><h3>Selected</h3><div id="detail"></div></div>
    <h3>Top brokers (by listings here)</h3>
    <div class="blist" id="blist"></div>
  </aside>
</div>
<script>
const $=s=>document.querySelector(s);
let GRAPH=null, network=null, nodesDS=null, edgesDS=null, minListings=1;

function buildVis(g){
  const visNodes=[], visEdges=[];
  g.nodes.forEach(n=>{
    if(n.kind==='firm'){
      visNodes.push({id:n.id,label:n.label,shape:'box',color:{background:'#16324f',border:'#58a6ff'},font:{color:'#cfe3ff',size:13},value:Math.max(8,n.weight*4),group:'firm'});
    } else {
      const ok = (n.listings||0) >= minListings;
      visNodes.push({id:n.id,label:n.label,shape:'dot',color:{background: ok?'#2ea043':'#23502f',border:'#3fb950'},font:{color: ok?'#e6edf3':'#5a6b5e',size:12},value:Math.max(6,(n.listings||1)*6),title:`${n.label}\n${n.firm||'—'}\n${n.listings} listings here / ${n.total_assets||'?'} total`,group:'broker',_listings:n.listings||0});
    }
  });
  g.edges.forEach(e=>{
    if(e.kind==='employs') visEdges.push({from:e.source,to:e.target,color:{color:'#2a313c'},width:1});
    else visEdges.push({from:e.source,to:e.target,color:{color:'#d29922'},width:Math.min(6,1+(e.weight||1)),title:`co-listed ${e.weight||1}×`});
  });
  nodesDS=new vis.DataSet(visNodes); edgesDS=new vis.DataSet(visEdges);
  const opts={ physics:{stabilization:{iterations:180},barnesHut:{gravitationalConstant:-9000,springLength:130,springConstant:.03}},
    interaction:{hover:true,tooltipDelay:120}, nodes:{scaling:{min:6,max:48}}, edges:{smooth:{type:'continuous'}} };
  network=new vis.Network($('#net'), {nodes:nodesDS,edges:edgesDS}, opts);
  network.on('click', p=>{ if(p.nodes.length) showDetail(p.nodes[0]); });
}
function showDetail(id){
  const g=GRAPH; const n=g.nodes.find(x=>x.id===id); if(!n) return;
  const wrap=$('#detailwrap'), d=$('#detail'); wrap.style.display='block';
  if(n.kind==='firm'){
    const brokers=g.nodes.filter(x=>x.kind==='broker'&&x.firm===n.label);
    d.innerHTML=`<b>${n.label}</b><div class="k">brokerage firm</div><div style="margin-top:6px">${brokers.length} brokers in graph:</div>`+brokers.slice(0,30).map(b=>`<div>· ${b.label} <span style="color:var(--acc)">(${b.listings})</span></div>`).join('');
  } else {
    const co=g.edges.filter(e=>e.kind==='colist'&&(e.source===id||e.target===id));
    const partners=co.map(e=>{const oid=e.source===id?e.target:e.source;const o=g.nodes.find(x=>x.id===oid);return o?`${o.label} (${e.weight}×)`:''}).filter(Boolean);
    // Inline contact (from the graph payload), then lazy-load the full enriched card + book.
    const esc=s=>(s||'').replace(/</g,'&lt;');
    const contact=[
      n.phone?`<div>📞 <a href="tel:${esc(n.phone)}" style="color:var(--blue)">${esc(n.phone)}</a></div>`:'',
      n.email?`<div>✉️ <a href="mailto:${esc(n.email)}" style="color:var(--blue)">${esc(n.email)}</a></div>`:'',
      n.website?`<div>🌐 <a href="${esc(n.website)}" target="_blank" rel="noopener noreferrer" style="color:var(--blue)">${esc(n.website.replace(/^https?:\/\//,'').slice(0,34))}</a></div>`:'',
      n.linkedin?`<div>💼 <a href="${esc(n.linkedin)}" target="_blank" rel="noopener noreferrer" style="color:var(--blue)">LinkedIn</a></div>`:'',
      n.office_addr?`<div class="k" style="font-size:11px">🏢 ${esc(n.office_addr)}</div>`:''
    ].filter(Boolean).join('');
    d.innerHTML=`<b>${esc(n.label)}</b><div class="k">${esc(n.firm)||'—'}</div>`+
      `<div style="margin-top:6px">${n.listings} listings here · ${n.total_assets||'?'} total on Crexi</div>`+
      (contact?`<div style="margin-top:8px;padding:8px;background:var(--card);border-radius:8px">${contact}</div>`:'<div class="k" style="margin-top:8px;font-size:11px">no contact info enriched</div>')+
      (partners.length?`<div style="margin-top:8px" class="k">co-lists with:</div>${partners.map(p=>'<div>· '+esc(p)+'</div>').join('')}`:'')+
      `<div id="bookwrap" class="k" style="margin-top:10px;font-size:11px">loading book…</div>`;
    if(n.dbId!=null) fetch('/api/brokers/contact/'+n.dbId).then(r=>r.json()).then(c=>{
      const bw=document.getElementById('bookwrap'); if(!bw) return;
      const prov=(c.provenance||[]).map(p=>p.field+'←'+(p.tier||'').replace(/^tier\d-/,'')).join(', ');
      const book=(c.other_listings||[]).slice(0,8);
      bw.innerHTML=(book.length?`<div class="k">other listings on the open web (${c.other_listings.length}):</div>`+book.map(x=>`<div>· ${esc(x.title||x.address||'')} ${x.city?'('+esc(x.city)+')':''}</div>`).join(''):`<div class="k">book: ${c.total_assets||'?'} total on Crexi (no public profile book pulled)</div>`)+
        (prov?`<div class="k" style="margin-top:6px;opacity:.7">source: ${esc(prov)}</div>`:'');
    }).catch(()=>{ const bw=document.getElementById('bookwrap'); if(bw) bw.textContent=''; });
  }
  network.selectNodes([id]); network.focus(id,{scale:1.1,animation:true});
}
function applyMinL(){ if(!nodesDS) return;
  nodesDS.forEach(nd=>{ if(nd.group==='broker'){ const ok=(nd._listings||0)>=minListings; nodesDS.update({id:nd.id,color:{background:ok?'#2ea043':'#23502f',border:'#3fb950'},font:{color:ok?'#e6edf3':'#5a6b5e',size:12}}); } });
}
function highlight(q){ if(!nodesDS) return; q=q.trim().toLowerCase();
  nodesDS.forEach(nd=>{ const hit=q&&nd.label.toLowerCase().includes(q); nodesDS.update({id:nd.id,borderWidth:hit?4:1,font:{...(nd.font||{}),color:hit?'#fff':(nd.font&&nd.font.color)||'#e6edf3'}}); });
}

fetch('/api/graph?limit=500').then(r=>r.json()).then(g=>{
  GRAPH=g;
  if(g.unavailable || !g.nodes.length){ $('#net').innerHTML='<div class="empty">No broker data yet.<br>Run: <code>CC_LIMIT=0 node scripts/fetch-brokers.js</code></div>'; }
  else buildVis(g);
  const s=g.stats||{};
  $('#stats').innerHTML=`<span><b>${s.brokers||0}</b> brokers</span><span><b>${s.firms||0}</b> firms</span><span><b>${s.listings||0}</b> listings</span><span><b>${s.edges||0}</b> links</span>`;
  fetch('/api/brokers/enrich-stats').then(r=>r.json()).then(e=>{
    if(e&&e.total) $('#stats').innerHTML += `<span><b>${e.contactable}</b> contactable</span><span style="color:var(--mut)">✉️${e.email} 📞${e.phone} 💼${e.linkedin}</span>`;
  }).catch(()=>{});
}).catch(e=>{ $('#net').innerHTML='<div class="empty">graph error: '+e.message+'</div>'; });

fetch('/api/brokers/top?limit=40').then(r=>r.json()).then(rows=>{
  $('#blist').innerHTML = (rows||[]).map(t=>`<div class="row" data-name="${(t.name||'').replace(/"/g,'&quot;')}"><span>${t.name}<div class="firm">${t.firm||'—'}</div></span><span class="n">${t.listings}</span></div>`).join('') || '<div class="k">none yet</div>';
  $('#blist').onclick=e=>{ const r=e.target.closest('.row'); if(r){ $('#firmq').value=r.dataset.name; highlight(r.dataset.name); } };
}).catch(()=>{});

$('#minl').oninput=()=>{ minListings=+$('#minl').value; $('#mlval').textContent=minListings; applyMinL(); };
$('#firmq').oninput=()=>highlight($('#firmq').value);
</script>
</body>
</html>