← back to Commercialrealestate

public/brokers.html

423 lines

<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}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='%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{margin-top:10px;display:flex;flex-direction:column;gap:5px;}
  .legend-item{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--mut);}
  .legend-line{width:28px;height:3px;display:inline-block;flex:0 0 28px;}
  .legend-line.solid{background:#58a6ff;}
  .legend-line.dashed{background:transparent;border-top:2px dashed #6e7d9a;height:0;}
  .legend-line.colist{background:#d29922;}
  .wa-count{opacity:.6;font-variant-numeric:tabular-nums;}
  .tog.empty{opacity:.55;}
  .dot{width:10px;height:10px;border-radius:50%;display:inline-block;}
  /* Toggle buttons */
  .toggle-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;}
  .tog{display:flex;align-items:center;gap:5px;cursor:pointer;font-size:12px;color:var(--mut);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:4px 8px;user-select:none;transition:border-color .15s,color .15s;}
  .tog.on{border-color:var(--blue);color:var(--ink);}
  .tog .dot-sm{width:8px;height:8px;border-radius:50%;flex:0 0 8px;}
  .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>
  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</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>

    <div class="ctrl">
      <h3>Edge Layers</h3>
      <div class="toggle-row">
        <div class="tog on" id="tog-employs" title="Broker → current firm">
          <span class="dot-sm" style="background:#58a6ff"></span> Current firm
        </div>
        <div class="tog on" id="tog-colist" title="Brokers who co-listed deals together">
          <span class="dot-sm" style="background:#d29922"></span> Co-listed
        </div>
        <div class="tog on" id="tog-worked_at" title="Broker → past firm — seeded but empty until LinkedIn work-history is loaded (phase 2)">
          <span class="dot-sm" style="background:#6e7d9a"></span> Past firm <span id="wa-count" class="wa-count"></span>
        </div>
      </div>

      <div class="legend">
        <div class="legend-item"><span class="legend-line solid"></span> current firm (employs)</div>
        <div class="legend-item"><span class="legend-line dashed"></span> past firm (worked at)</div>
        <div class="legend-item"><span class="legend-line colist"></span> co-listed together</div>
        <div class="legend-item"><i class="dot" style="background:#58a6ff"></i> firm node</div>
        <div class="legend-item"><i class="dot" style="background:#3fb950"></i> broker node</div>
      </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);

// ---------------------------------------------------------------------------
// Toggle state (persisted in localStorage)
// ---------------------------------------------------------------------------
const TOGGLE_KEY = 'brokermm-edge-toggles';
function loadToggles(){
  try { return JSON.parse(localStorage.getItem(TOGGLE_KEY)) || {}; } catch(e){ return {}; }
}
function saveToggles(state){
  try { localStorage.setItem(TOGGLE_KEY, JSON.stringify(state)); } catch(e){}
}
// Defaults: all on
const toggleState = Object.assign({employs:true, colist:true, worked_at:true}, loadToggles());

function syncToggleUI(){
  ['employs','colist','worked_at'].forEach(k=>{
    const el=$('#tog-'+k);
    if(el) el.classList.toggle('on', !!toggleState[k]);
  });
}

// ---------------------------------------------------------------------------
// Graph state
// ---------------------------------------------------------------------------
let GRAPH=null, network=null, nodesDS=null, edgesDS=null, minListings=1;
// Raw edges stored separately so we can re-apply toggles without re-fetching
let ALL_EDGES = []; // array of vis edge objects with extra _kind prop

// ---------------------------------------------------------------------------
// Build vis.js graph
// ---------------------------------------------------------------------------
function buildVis(g){
  const visNodes=[], visEdges=[];
  ALL_EDGES = [];

  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||1)*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||0} listings here / ${n.total_assets||'?'} total`,
        group:'broker', _listings:n.listings||0
      });
    }
  });

  g.edges.forEach(e=>{
    let edgeObj;
    if(e.kind==='employs'){
      edgeObj = {
        from:e.source, to:e.target,
        color:{color:'#2a313c', opacity:1},
        width:1.5,
        smooth:{type:'continuous'},
        dashes:false,
        _kind:'employs'
      };
    } else if(e.kind==='colist'){
      const w = Math.min(6, 1+(e.weight||1));
      edgeObj = {
        from:e.source, to:e.target,
        color:{color:'#d29922', opacity:0.75},
        width:w,
        smooth:{type:'curvedCW', roundness:0.25},
        dashes:false,
        title:`co-listed ${e.weight||1}×`,
        _kind:'colist'
      };
    } else if(e.kind==='worked_at'){
      edgeObj = {
        from:e.source, to:e.target,
        color:{color:'#6e7d9a', opacity:0.6},
        width:1,
        smooth:{type:'continuous'},
        dashes:[6,4],
        title:'previously worked at',
        _kind:'worked_at'
      };
    } else {
      // Unknown edge kind — render neutrally
      edgeObj = {
        from:e.source, to:e.target,
        color:{color:'#3a4455'},
        width:1, _kind:e.kind||'unknown'
      };
    }
    ALL_EDGES.push(edgeObj);
  });

  // Honest signal: the past-firm layer is seeded but empty until work-history is loaded.
  const waCount = ALL_EDGES.filter(e=>e._kind==='worked_at').length;
  const waEl=$('#wa-count'); if(waEl) waEl.textContent='('+waCount+')';
  const waTog=$('#tog-worked_at'); if(waTog) waTog.classList.toggle('empty', waCount===0);

  const activeEdges = ALL_EDGES.filter(e=>toggleState[e._kind]!==false);

  nodesDS = new vis.DataSet(visNodes);
  edgesDS = new vis.DataSet(activeEdges);

  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]); });
}

// ---------------------------------------------------------------------------
// Apply toggle — show/hide edge kinds without rebuilding the graph
// ---------------------------------------------------------------------------
function applyToggles(){
  if(!edgesDS) return;
  const active = ALL_EDGES.filter(e=>toggleState[e._kind]!==false);
  // Remove all edges then re-add the active set
  edgesDS.clear();
  edgesDS.add(active);
  saveToggles(toggleState);
}

// ---------------------------------------------------------------------------
// Show detail panel on node click
// ---------------------------------------------------------------------------
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'){
    // Collect current brokers (employs edges)
    const brokers=g.nodes.filter(x=>x.kind==='broker' && x.firm===n.label);
    // Past brokers (worked_at edges pointing to this firm)
    const pastBrokerIds = new Set(
      g.edges.filter(e=>e.kind==='worked_at' && e.target===id).map(e=>e.source)
    );
    const pastBrokers = g.nodes.filter(x=>x.kind==='broker' && pastBrokerIds.has(x.id) && x.firm!==n.label);

    d.innerHTML =
      `<b>${n.label}</b><div class="k">brokerage firm</div>` +
      `<div style="margin-top:6px">${brokers.length} current broker${brokers.length!==1?'s':''} in graph:</div>` +
      brokers.slice(0,30).map(b=>`<div>· ${b.label} <span style="color:var(--acc)">(${b.listings||0})</span></div>`).join('') +
      (pastBrokers.length ? `<div style="margin-top:8px" class="k">Alumni (${pastBrokers.length}):</div>` +
        pastBrokers.slice(0,15).map(b=>`<div style="opacity:.65">· ${b.label}</div>`).join('') : '');

  } else {
    // Broker detail
    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||1}×)` : '';
    }).filter(Boolean);

    // Build firm chain: current + past
    const currentFirmEdge = g.edges.find(e=>e.kind==='employs' && e.source===id);
    const pastFirmIds = g.edges.filter(e=>e.kind==='worked_at' && e.source===id).map(e=>e.target);
    const pastFirms = pastFirmIds.map(fid=>g.nodes.find(x=>x.id===fid)).filter(Boolean);

    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('');

    const firmChain = pastFirms.length
      ? `<div style="margin-top:6px;font-size:12px" class="k">Firm history:</div>` +
        `<div style="font-size:12px">→ ${esc(n.firm||'current')}</div>` +
        pastFirms.map(f=>`<div style="font-size:12px;opacity:.65">← ${esc(f.label)}</div>`).join('')
      : '';

    d.innerHTML =
      `<b>${esc(n.label)}</b>` +
      `<div class="k">${esc(n.firm)||'—'}</div>` +
      `<div style="margin-top:6px">${n.listings||0} listings here · ${n.total_assets||'?'} total</div>` +
      `<div style="margin-top:8px"><a href="/agent.html?name=${encodeURIComponent(n.label)}${n.dbId!=null?'&id='+encodeURIComponent(n.dbId):''}" style="color:var(--gold);font-weight:600">View full profile →</a></div>` +
      firmChain +
      (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 listings on record</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});
}

// ---------------------------------------------------------------------------
// Min-listings filter (dims dim brokers, doesn't hide them)
// ---------------------------------------------------------------------------
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}});
    }
  });
}

// ---------------------------------------------------------------------------
// Search highlight
// ---------------------------------------------------------------------------
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'}});
  });
}

// ---------------------------------------------------------------------------
// Data fetch — try ?history=1 first, fall back to base graph
// ---------------------------------------------------------------------------
function hasHistoryEdges(g){
  return g && g.edges && g.edges.some(e=>e.kind==='colist'||e.kind==='worked_at');
}

// If this page was opened via a credentialed URL (http://user:pass@host/…), the browser
// keeps those credentials on document.baseURI, and a relative fetch('/api/…') inherits them —
// which fetch() hard-rejects ("URL that includes credentials"). Resolve every relative API
// path against location.origin (scheme://host:port, never userinfo) so no fetch can inherit
// them. Basic-auth still flows: the browser sends its cached Authorization header same-origin.
(function(){
  const origin = location.origin, _fetch = window.fetch.bind(window);
  window.fetch = (input, init) =>
    _fetch(typeof input === 'string' && input.startsWith('/') ? origin + input : input, init);
})();

function loadGraph(){
  const historyUrl = '/api/graph?limit=500&history=1';
  const baseUrl    = '/api/graph?limit=500';

  return fetch(historyUrl)
    .then(r=>{ if(!r.ok) throw new Error('history endpoint '+r.status); return r.json(); })
    .then(g=>{
      // If the server returned history data use it; otherwise fall back silently
      if(hasHistoryEdges(g) || (g.nodes && g.nodes.length)) return g;
      return fetch(baseUrl).then(r=>r.json());
    })
    .catch(()=> fetch(baseUrl).then(r=>r.json()));
}

loadGraph().then(g=>{
  GRAPH=g;
  if(g.unavailable || !g.nodes || !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>';
    return;
  }
  buildVis(g);
  syncToggleUI();

  const s=g.stats||{};
  const colistCount = (g.edges||[]).filter(e=>e.kind==='colist').length;
  const workedAtCount = (g.edges||[]).filter(e=>e.kind==='worked_at').length;
  let statsHtml = `<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>`;
  if(colistCount)   statsHtml += `<span><b>${colistCount}</b> co-list edges</span>`;
  if(workedAtCount) statsHtml += `<span><b>${workedAtCount}</b> alumni edges</span>`;
  $('#stats').innerHTML = statsHtml;

  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>'; });

// ---------------------------------------------------------------------------
// Top brokers sidebar list
// ---------------------------------------------------------------------------
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(()=>{});

// ---------------------------------------------------------------------------
// UI event wiring
// ---------------------------------------------------------------------------
$('#minl').oninput=()=>{ minListings=+$('#minl').value; $('#mlval').textContent=minListings; applyMinL(); };
$('#firmq').oninput=()=>highlight($('#firmq').value);

// Toggle buttons
['employs','colist','worked_at'].forEach(k=>{
  const el=$('#tog-'+k);
  if(!el) return;
  el.addEventListener('click',()=>{
    toggleState[k] = !toggleState[k];
    el.classList.toggle('on', toggleState[k]);
    applyToggles();
  });
});
</script>
<script src="/corner-nav.js" defer></script>
  <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
</html>