← 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 →