[object Object]

← back to Rentv Ad Engine

analytics: guest<->sponsor network graph (zero-dep force-directed canvas — gold sponsor hubs sized by reach, teal guests, links = shared episodes)

e0aef11549fa15745d84d35e1321ba20dec68a90 · 2026-08-07 14:14:13 -0700 · Steve Abrams

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

Files touched

Diff

commit e0aef11549fa15745d84d35e1321ba20dec68a90
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 14:14:13 2026 -0700

    analytics: guest<->sponsor network graph (zero-dep force-directed canvas — gold sponsor hubs sized by reach, teal guests, links = shared episodes)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/charts.html | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 53 insertions(+)

diff --git a/public/charts.html b/public/charts.html
index 7071b17..03f3359 100644
--- a/public/charts.html
+++ b/public/charts.html
@@ -121,6 +121,9 @@
       <div class="panel" id="overlap"></div>
     </div>
   </div>
+
+  <h2>Guest ↔ sponsor network <span class="muted" style="text-transform:none;font-weight:400">— gold = sponsor, teal = guest; a link = they shared an episode</span></h2>
+  <div class="panel" style="overflow:hidden"><canvas id="net" width="1080" height="560" style="width:100%;max-width:1080px;height:auto"></canvas></div>
 </main>
 
 <script>
@@ -163,6 +166,7 @@ async function boot(){
   renderPerClient(clients,airings);
   renderGuestMap(guests);
   renderOverlap(airings);
+  renderNetwork(airings,guests);
 }
 
 // ── LIQUID FILL GAUGES (animated wave) ──
@@ -475,6 +479,55 @@ function renderKpis(clients,airings,guests){
   $("#asof").textContent="— RENTV CRE Talk advertiser report · generated "+d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric'});
 }
 
+// ── GUEST ↔ SPONSOR NETWORK (zero-dep force-directed canvas) ──
+function renderNetwork(airings,guests){
+  const cv=$("#net"); if(!cv)return; const ctx=cv.getContext("2d"), W=cv.width, H=cv.height;
+  // episode -> sponsors, episode -> guests
+  const epSpon={}, epGuest={};
+  airings.forEach(a=>{(epSpon[a.episode_id]=epSpon[a.episode_id]||new Set()).add(a.advertiser);});
+  guests.forEach(g=>{if(g.name)(epGuest[g.episode_id]=epGuest[g.episode_id]||new Set()).add(g.name);});
+  // rank sponsors by #episodes, keep top 10
+  const sponEps={};Object.values(epSpon).forEach(set=>set.forEach(s=>sponEps[s]=(sponEps[s]||0)+1));
+  const topSpon=new Set(Object.entries(sponEps).sort((a,b)=>b[1]-a[1]).slice(0,10).map(e=>e[0]));
+  // build nodes + links
+  const N={}, links=[];
+  const add=(id,type)=>{if(!N[id])N[id]={id,type,x:W/2+(Math.random()-.5)*300,y:H/2+(Math.random()-.5)*220,vx:0,vy:0,deg:0};return N[id];};
+  Object.keys(epSpon).forEach(ep=>{
+    const sp=[...(epSpon[ep]||[])].filter(s=>topSpon.has(s));
+    const gs=[...(epGuest[ep]||[])];
+    if(!sp.length||!gs.length)return;
+    sp.forEach(s=>{add(s,"sponsor");gs.forEach(g=>{add(g,"guest");links.push([s,g]);N[s].deg++;N[g].deg++;});});
+  });
+  const nodes=Object.values(N);
+  if(nodes.length<2){ctx.fillStyle="#9aa7b4";ctx.font="13px -apple-system";ctx.fillText("Not enough linked episodes to graph.",20,30);return;}
+  // dedupe links
+  const seen=new Set();const L=links.filter(([a,b])=>{const k=a+"|"+b;if(seen.has(k))return false;seen.add(k);return true;});
+  // force sim
+  const K=Math.sqrt((W*H)/nodes.length)*0.6; let alpha=1;
+  function tick(){
+    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,d=Math.hypot(dx,dy)||1;const f=(K*K/d)*0.02*alpha;
+        dx/=d;dy/=d;a.vx+=dx*f;a.vy+=dy*f;b.vx-=dx*f;b.vy-=dy*f;}}
+    L.forEach(([ai,bi])=>{const a=N[ai],b=N[bi];let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;
+      const f=((d-K)/d)*0.06*alpha;a.vx+=dx*f;a.vy+=dy*f;b.vx-=dx*f;b.vy-=dy*f;});
+    nodes.forEach(n=>{n.vx+=(W/2-n.x)*0.002*alpha;n.vy+=(H/2-n.y)*0.002*alpha;
+      n.x+=(n.vx*=0.85);n.y+=(n.vy*=0.85);n.x=Math.max(30,Math.min(W-30,n.x));n.y=Math.max(24,Math.min(H-24,n.y));});
+    alpha*=0.985;
+    // draw
+    ctx.clearRect(0,0,W,H);
+    ctx.strokeStyle="rgba(154,167,180,.16)";ctx.lineWidth=1;
+    L.forEach(([ai,bi])=>{const a=N[ai],b=N[bi];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();});
+    nodes.forEach(n=>{const sp=n.type==="sponsor";const r=sp?Math.min(20,8+n.deg*0.6):Math.min(9,4+n.deg*0.8);
+      ctx.beginPath();ctx.arc(n.x,n.y,r,0,Math.PI*2);ctx.fillStyle=sp?"#d4af37":"#2dd4bf";ctx.globalAlpha=sp?.95:.8;ctx.fill();ctx.globalAlpha=1;
+      if(sp){ctx.fillStyle="#e6edf3";ctx.font="600 11px -apple-system,Segoe UI";ctx.textAlign="center";
+        ctx.fillText(n.id.replace(/ (Group|Companies|Savings Bank|Property Tax Appeal|Real Estate Inspectors|Mortgage Lenders)$/,"").slice(0,16),n.x,n.y-r-3);}});
+    if(alpha>0.02)requestAnimationFrame(tick);
+  }
+  tick();
+  cv.title=`${nodes.filter(n=>n.type==='sponsor').length} sponsors · ${nodes.filter(n=>n.type==='guest').length} guests · ${L.length} links`;
+}
+
 boot();
 </script>
 </body>

← 9302761 fix(deploy): HEALTH_URL 9789->9793 (app's real remote port;  ·  back to Rentv Ad Engine  ·  analytics: sponsor-tenure Gantt (first->last episode per cli 74b0fd7 →