[object Object]

← back to Rentv

crm: cross-reference mind map (/crm-mindmap) — force-directed graph of LABJ contacts x RE build DB

a1db61ae1144acc54cdb3bf89d853bb8a2ed6ab6 · 2026-08-12 16:54:40 -0700 · Steve Abrams

- interactive canvas graph: LABJ2026 hub -> list-category branches -> matched contacts -> RE-DB match clusters -> RE Build DB hub (CRCP brokers + owners)
- drag nodes, scroll-zoom, pan, click node -> detail panel with each matched CRE/owner record; min-match slider + firm/name filter
- linked from CRM drawer (Cross-Ref Mind Map); admin-gated route; dependency-free (no CDN)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a1db61ae1144acc54cdb3bf89d853bb8a2ed6ab6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 16:54:40 2026 -0700

    crm: cross-reference mind map (/crm-mindmap) — force-directed graph of LABJ contacts x RE build DB
    
    - interactive canvas graph: LABJ2026 hub -> list-category branches -> matched contacts -> RE-DB match clusters -> RE Build DB hub (CRCP brokers + owners)
    - drag nodes, scroll-zoom, pan, click node -> detail panel with each matched CRE/owner record; min-match slider + firm/name filter
    - linked from CRM drawer (Cross-Ref Mind Map); admin-gated route; dependency-free (no CDN)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/crm-mindmap.html | 179 ++++++++++++++++++++++++++++++++++++++++++++++++
 public/crm.html         |   1 +
 server.js               |   1 +
 3 files changed, 181 insertions(+)

diff --git a/public/crm-mindmap.html b/public/crm-mindmap.html
new file mode 100644
index 00000000..e84c7823
--- /dev/null
+++ b/public/crm-mindmap.html
@@ -0,0 +1,179 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV · CRM Cross-Reference Mind Map</title>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@800&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
+<style>
+  :root{--red:#c8102e;--line:#232a32;--bg:#0d1014}
+  *{box-sizing:border-box;margin:0}
+  html,body{height:100%}
+  body{background:var(--bg);color:#e7ecf1;font-family:'Inter',system-ui,sans-serif;overflow:hidden}
+  a{color:inherit;text-decoration:none}
+  .top{position:fixed;top:0;left:0;right:0;height:50px;display:flex;align-items:center;gap:14px;padding:0 14px;background:#0b0e12;border-bottom:1px solid var(--line);z-index:10}
+  .brand{font-family:'Playfair Display',serif;font-weight:800;font-size:18px}.brand span{color:var(--red)}
+  .top .sub{font-size:12px;color:#8b939b;letter-spacing:.04em}
+  .top .sp{flex:1}
+  .top a.back{font-size:12px;font-weight:800;color:#c3cbd3;border:1px solid var(--line);border-radius:8px;padding:7px 12px}.top a.back:hover{border-color:var(--red);color:#fff}
+  .controls{position:fixed;top:58px;left:12px;z-index:9;background:rgba(11,14,18,.92);border:1px solid var(--line);border-radius:12px;padding:12px 13px;width:232px;backdrop-filter:blur(4px)}
+  .controls h3{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#6b747d;margin-bottom:9px}
+  .controls label{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;color:#9aa4ad;margin:8px 0}
+  .controls input[type=range]{accent-color:var(--red);width:118px}
+  .controls input[type=search]{width:100%;background:#11161c;border:1px solid var(--line);border-radius:8px;color:#e7ecf1;padding:7px 9px;font:inherit;margin-top:4px}
+  .controls .btn{width:100%;margin-top:9px;background:#11161c;border:1px solid var(--line);color:#dbe1e7;border-radius:8px;padding:8px;font:700 12px 'Inter';cursor:pointer}.controls .btn:hover{border-color:var(--red);color:#fff}
+  .legend{display:flex;flex-direction:column;gap:5px;margin-top:11px;font-size:11.5px;color:#c3cbd3}
+  .legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;vertical-align:middle}
+  .stat{font-size:11px;color:#6b747d;margin-top:9px;line-height:1.5}
+  canvas{position:fixed;inset:0;top:50px;display:block;cursor:grab;z-index:2;background:var(--bg)}
+  canvas.drag{cursor:grabbing}
+  /* neutralize any site chrome the server injects into this full-screen app */
+  body>footer,body>.fe-be,body>.site-footer,body>nav.footer{display:none!important}
+  .panel{position:fixed;top:58px;right:12px;width:340px;max-height:calc(100vh - 72px);overflow:auto;background:rgba(11,14,18,.96);border:1px solid var(--line);border-radius:12px;padding:15px 16px;z-index:9;transform:translateX(115%);transition:transform .24s;box-shadow:-2px 0 30px rgba(0,0,0,.5)}
+  .panel.open{transform:none}
+  .panel .x{float:right;background:none;border:0;color:#8b949e;font-size:19px;cursor:pointer}
+  .panel .k{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--red);font-weight:800}
+  .panel h2{font-family:'Playfair Display',serif;font-size:20px;margin:3px 0 4px}
+  .panel .meta{font-size:12.5px;color:#9aa4ad;margin-bottom:10px}
+  .panel .mrow{border-top:1px solid var(--line);padding:8px 0;font-size:12.5px}
+  .panel .mrow .nm{font-weight:700}.panel .mrow .sub{color:#8b939b;font-size:11.5px}
+  .hint{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);font-size:11.5px;color:#6b747d;background:rgba(11,14,18,.8);border:1px solid var(--line);border-radius:20px;padding:6px 14px;z-index:9}
+</style></head>
+<body>
+<header class="top">
+  <span class="brand">REN<span>TV</span></span>
+  <span class="sub">CRM · Cross-Reference Mind Map</span>
+  <span class="sp"></span>
+  <a class="back" href="/crm">← Back to CRM</a>
+</header>
+<div class="controls">
+  <h3>Cross-Reference Map</h3>
+  <label>Min matches <span id="mmv">1</span><input id="mm" type="range" min="1" max="25" value="1"></label>
+  <input id="q" type="search" placeholder="Filter firm / name / list…">
+  <button class="btn" id="reset">Reset view</button>
+  <div class="legend">
+    <div><i style="background:#c8102e"></i>LABJ2026 hub</div>
+    <div><i style="background:#4ea1ff"></i>RE build DB (CRCP + owners)</div>
+    <div><i style="background:#37d39b"></i>LABJ list category</div>
+    <div><i style="background:#e7ecf1"></i>Matched contact</div>
+    <div><i style="background:#f2c14e"></i>RE-DB match cluster</div>
+  </div>
+  <div class="stat" id="stat">loading…</div>
+</div>
+<canvas id="c"></canvas>
+<aside class="panel" id="panel"><button class="x" onclick="closePanel()">✕</button><div id="pbody"></div></aside>
+<div class="hint">drag nodes · scroll to zoom · drag background to pan · click a node for detail</div>
+<script>
+const $=id=>document.getElementById(id);
+const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const _xnorm=s=>String(s||'').toLowerCase().replace(/\b(inc|llc|llp|lp|ltd|co|corp|corporation|company|group|the|and|of|assoc|associates|partners|realty|real|estate)\b/g,' ').replace(/[^a-z0-9]+/g,' ').trim();
+const typeLabel=t=>({commercial:'CRE Broker',owner:'Property Owner'}[t]||t);
+let RAW=[], NODES=[], EDGES=[], byId={};
+const cv=$('c'), ctx=cv.getContext('2d');
+let DPR=Math.min(2,window.devicePixelRatio||1), W=0,H=0;
+let view={x:0,y:0,z:1}, drag=null, panning=null, hover=null;
+
+function resize(){ W=cv.clientWidth=window.innerWidth; H=cv.clientHeight=window.innerHeight-50; cv.width=W*DPR; cv.height=H*DPR; ctx.setTransform(DPR,0,0,DPR,0,0); }
+window.addEventListener('resize',()=>{resize();});
+
+function buildXref(contacts){
+  const byFirm=new Map(), byName=new Map(), push=(m,k,v)=>{if(!k)return;const a=m.get(k);a?a.push(v):m.set(k,[v]);};
+  for(const c of contacts){ if(c.type==='commercial'||c.type==='owner'){ push(byFirm,_xnorm(c.firm),c); const nn=_xnorm(c.name); if(nn.split(' ').length>=2) push(byName,nn,c);} }
+  for(const c of contacts){ if(c.type!=='directory')continue; const seen=new Set(),hits=[];
+    for(const m of (byFirm.get(_xnorm(c.firm))||[])) if(!seen.has(m.id)){seen.add(m.id);hits.push(m);}
+    const nn=_xnorm(c.name); if(nn.split(' ').length>=2) for(const m of (byName.get(nn)||[])) if(!seen.has(m.id)){seen.add(m.id);hits.push(m);}
+    if(hits.length) c.xref={count:hits.length, matches:hits.slice(0,40).map(h=>({name:h.name||h.firm,type:h.type,dre:h.dre_license||'',city:h.city||''}))};
+  }
+}
+function rnd(seed){ let x=Math.sin(seed*99991)*10000; return x-Math.floor(x); }
+function buildGraph(){
+  const minm=+$('mm').value, q=$('q').value.trim().toLowerCase();
+  NODES=[]; EDGES=[]; byId={};
+  const add=(n)=>{ byId[n.id]=n; NODES.push(n); return n; };
+  const hub=add({id:'__hub',t:'hub',label:'LABJ2026',r:26,color:'#c8102e',x:W/2-160,y:H/2,fx:W/2-160,fy:H/2});
+  const redb=add({id:'__redb',t:'redb',label:'RE Build DB',r:24,color:'#4ea1ff',x:W/2+220,y:H/2,fx:W/2+220,fy:H/2});
+  const contacts=RAW.filter(c=>c.type==='directory'&&c.xref&&c.xref.count>=minm&&(!q||[c.name,c.firm,c.list_category].filter(Boolean).join(' ').toLowerCase().includes(q)));
+  const cats={};
+  contacts.forEach((c,i)=>{
+    const cat=c.list_category||'Other';
+    if(!cats[cat]){ const a=rnd(i+1)*Math.PI*2; cats[cat]=add({id:'cat:'+cat,t:'cat',label:cat,r:11,color:'#37d39b',x:W/2+Math.cos(a)*120,y:H/2+Math.sin(a)*120,n:0}); EDGES.push(['__hub','cat:'+cat,.02]); }
+    cats[cat].n++;
+    const cid='c:'+c.id, a=rnd(i+7)*Math.PI*2;
+    add({id:cid,t:'contact',ref:c,label:c.name||c.firm||'(contact)',r:Math.min(10,4+Math.sqrt(c.xref.count)),color:'#e7ecf1',x:W/2+Math.cos(a)*220,y:H/2+Math.sin(a)*220});
+    EDGES.push(['cat:'+cat,cid,.03]);
+    const mid='m:'+c.id, a2=rnd(i+13)*Math.PI*2;
+    add({id:mid,t:'match',ref:c,label:'⇄ '+c.xref.count,count:c.xref.count,r:Math.min(16,5+Math.sqrt(c.xref.count)*1.4),color:'#f2c14e',x:W/2+180+Math.cos(a2)*160,y:H/2+Math.sin(a2)*160});
+    EDGES.push([cid,mid,.05]); EDGES.push([mid,'__redb',.02]);
+  });
+  $('stat').innerHTML=`${contacts.length} cross-referenced contacts<br>${Object.keys(cats).length} lists · ${contacts.reduce((s,c)=>s+c.xref.count,0).toLocaleString()} RE-DB links`;
+}
+function tick(){
+  const rep=1400;
+  for(let i=0;i<NODES.length;i++){ const a=NODES[i];
+    for(let j=i+1;j<NODES.length;j++){ const b=NODES[j];
+      let dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy+.01; if(d2>90000)continue; let f=rep/d2, d=Math.sqrt(d2); dx/=d;dy/=d;
+      a.vx=(a.vx||0)+dx*f; a.vy=(a.vy||0)+dy*f; b.vx=(b.vx||0)-dx*f; b.vy=(b.vy||0)-dy*f; }
+  }
+  for(const [s,t,k] of EDGES){ const a=byId[s],b=byId[t]; if(!a||!b)continue; let dx=b.x-a.x,dy=b.y-a.y,d=Math.sqrt(dx*dx+dy*dy)||1,f=(d-90)*k; dx/=d;dy/=d;
+    a.vx=(a.vx||0)+dx*f; a.vy=(a.vy||0)+dy*f; b.vx=(b.vx||0)-dx*f; b.vy=(b.vy||0)-dy*f; }
+  for(const n of NODES){ if(n.fx!=null){n.x=n.fx;n.y=n.fy;n.vx=n.vy=0;continue;} if(n===drag)continue;
+    n.vx=(n.vx||0)*.86; n.vy=(n.vy||0)*.86; n.x+=Math.max(-6,Math.min(6,n.vx)); n.y+=Math.max(-6,Math.min(6,n.vy)); }
+}
+function draw(){
+  ctx.clearRect(0,0,W,H);
+  ctx.save(); ctx.translate(view.x,view.y); ctx.scale(view.z,view.z);
+  ctx.lineWidth=1/view.z;
+  for(const [s,t] of EDGES){ const a=byId[s],b=byId[t]; if(!a||!b)continue;
+    ctx.strokeStyle=(b.t==='match'||a.t==='match')?'rgba(242,193,78,.16)':'rgba(120,140,160,.13)';
+    ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke(); }
+  for(const n of NODES){
+    ctx.beginPath();ctx.arc(n.x,n.y,n.r,0,7);ctx.fillStyle=n.color;ctx.globalAlpha=(n.t==='contact')?.92:1;ctx.fill();ctx.globalAlpha=1;
+    if(n===hover){ctx.strokeStyle='#fff';ctx.lineWidth=2/view.z;ctx.stroke();}
+    const showLabel=n.t==='hub'||n.t==='redb'||n.t==='cat'||n===hover||(n.t==='contact'&&view.z>1.15)||(n.t==='match'&&n.count>=8);
+    if(showLabel){ ctx.fillStyle=(n.t==='contact')?'#c3cbd3':(n.t==='cat'?'#9fead0':'#fff');
+      ctx.font=`${(n.t==='hub'||n.t==='redb')?800:700} ${Math.max(10,(n.t==='hub'||n.t==='redb')?14:(n.t==='cat'?12:11))/1}px Inter`;
+      ctx.textAlign='center'; ctx.fillText(n.label, n.x, n.y - n.r - 4/view.z); }
+  }
+  ctx.restore();
+}
+function loop(){ tick(); draw(); requestAnimationFrame(loop); }
+
+function screenToWorld(mx,my){ return {x:(mx-view.x)/view.z, y:(my-view.y)/view.z}; }
+function nodeAt(mx,my){ const p=screenToWorld(mx,my); let best=null,bd=1e9;
+  for(const n of NODES){ const d=Math.hypot(n.x-p.x,n.y-p.y); if(d<n.r+6&&d<bd){bd=d;best=n;} } return best; }
+cv.addEventListener('mousedown',e=>{ const n=nodeAt(e.clientX,e.clientY-50);
+  if(n&&n.t!=='hub'&&n.t!=='redb'){ drag=n; n.fx0=n.fx; cv.classList.add('drag'); } else { panning={x:e.clientX,y:e.clientY,vx:view.x,vy:view.y}; cv.classList.add('drag'); } });
+window.addEventListener('mousemove',e=>{ const my=e.clientY-50;
+  if(drag){ const p=screenToWorld(e.clientX,my); drag.x=p.x;drag.y=p.y;drag.vx=drag.vy=0; }
+  else if(panning){ view.x=panning.vx+(e.clientX-panning.x); view.y=panning.vy+(e.clientY-panning.y); }
+  else { hover=nodeAt(e.clientX,my); cv.style.cursor=hover?'pointer':'grab'; } });
+window.addEventListener('mouseup',e=>{ if(drag&&!moved(e)) openNode(drag); drag=null;panning=null;cv.classList.remove('drag'); });
+let downXY=null; cv.addEventListener('mousedown',e=>downXY={x:e.clientX,y:e.clientY});
+function moved(e){ return downXY&&Math.hypot(e.clientX-downXY.x,e.clientY-downXY.y)>4; }
+cv.addEventListener('click',e=>{ if(moved(e))return; const n=nodeAt(e.clientX,e.clientY-50); if(n)openNode(n); else closePanel(); });
+cv.addEventListener('wheel',e=>{ e.preventDefault(); const s=e.deltaY<0?1.12:1/1.12; const mx=e.clientX,my=e.clientY-50;
+  const wx=(mx-view.x)/view.z, wy=(my-view.y)/view.z; view.z=Math.max(.25,Math.min(4,view.z*s)); view.x=mx-wx*view.z; view.y=my-wy*view.z; },{passive:false});
+
+function openNode(n){
+  if(n.t==='contact'||n.t==='match'){ const c=n.ref;
+    $('pbody').innerHTML=`<div class="k">${esc(c.list_category||'Directory')}</div><h2>${esc(c.name||c.firm)}</h2>
+      <div class="meta">${esc([c.firm,c.title,[c.city,c.state].filter(Boolean).join(', ')].filter(Boolean).join(' · '))}${c.phone?' · '+esc(c.phone):''}</div>
+      <div class="k">⇄ ${c.xref.count} match${c.xref.count>1?'es':''} in RE build DB</div>
+      ${c.xref.matches.map(m=>`<div class="mrow"><div class="nm">${esc(m.name)}</div><div class="sub">${esc(typeLabel(m.type))}${m.dre?' · DRE '+esc(m.dre):''}${m.city?' · '+esc(m.city):''}</div></div>`).join('')}`;
+    $('panel').classList.add('open');
+  } else if(n.t==='cat'){ const rows=RAW.filter(c=>c.type==='directory'&&c.xref&&('cat:'+(c.list_category||'Other'))===n.id);
+    $('pbody').innerHTML=`<div class="k">List</div><h2>${esc(n.label)}</h2><div class="meta">${rows.length} cross-referenced contacts</div>
+      ${rows.sort((a,b)=>b.xref.count-a.xref.count).slice(0,40).map(c=>`<div class="mrow"><div class="nm">${esc(c.name||c.firm)} · ⇄ ${c.xref.count}</div><div class="sub">${esc(c.firm||'')}</div></div>`).join('')}`;
+    $('panel').classList.add('open');
+  } else if(n.t==='redb'){ $('pbody').innerHTML=`<div class="k">Cross-reference source</div><h2>RE Build DB</h2><div class="meta">CRCP commercial brokers + LA-assessor owners already loaded in this CRM. Every LABJ contact is matched into it by normalized firm + person name.</div>`; $('panel').classList.add('open'); }
+}
+window.closePanel=()=>$('panel').classList.remove('open');
+$('mm').oninput=()=>{ $('mmv').textContent=$('mm').value; buildGraph(); };
+$('q').oninput=()=>buildGraph();
+$('reset').onclick=()=>{ view={x:0,y:0,z:1}; buildGraph(); };
+
+(async()=>{
+  resize();
+  try{ const r=await fetch('/api/crm',{credentials:'same-origin'}); const j=await r.json(); RAW=j.contacts||[]; }
+  catch(e){ $('stat').textContent='load failed'; }
+  buildXref(RAW); buildGraph(); loop();
+})();
+</script>
+</body></html>
diff --git a/public/crm.html b/public/crm.html
index d8a0dbb4..73c84def 100644
--- a/public/crm.html
+++ b/public/crm.html
@@ -122,6 +122,7 @@
   <div class="gt">CRM</div>
   <nav>
     <a href="/crm">👥 Unified CRM</a>
+    <a href="/crm-mindmap">🕸️ Cross-Ref Mind Map</a>
     <a href="/desk">🗂️ Legacy Desk</a>
     <a href="/audience">📩 Legacy Audience</a>
   </nav>
diff --git a/server.js b/server.js
index 16c57592..928879c2 100644
--- a/server.js
+++ b/server.js
@@ -2395,6 +2395,7 @@ app.get('/api/crm', adminOnly, async (req, res) => {
   }
 });
 app.get('/crm', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'crm.html')));
+app.get('/crm-mindmap', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'crm-mindmap.html')));
 
 // ── Global site search — one box across every content surface. ──
 // Result hrefs are ALWAYS internal (or external YouTube for videos); we never

← ae07d2ff crm: full-width Table view — every scanned field a sortable  ·  back to Rentv  ·  crm: highlight in green everyone already followed/connected 857d4ae2 →