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