← back to Rentv Ad Engine
analytics: ad-spend flow sankey (category->sponsor) + new-advertiser acquisition chart (per-episode + cumulative roster)
14dc7183458ede18d9f377aee9d4104935619238 · 2026-08-07 14:21:53 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 14dc7183458ede18d9f377aee9d4104935619238
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 14:21:53 2026 -0700
analytics: ad-spend flow sankey (category->sponsor) + new-advertiser acquisition chart (per-episode + cumulative roster)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/charts.html | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/public/charts.html b/public/charts.html
index a104136..fc0a982 100644
--- a/public/charts.html
+++ b/public/charts.html
@@ -127,6 +127,17 @@
<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>
+
+ <div class="two">
+ <div>
+ <h2>Ad-spend flow <span class="muted" style="text-transform:none;font-weight:400">— category → sponsor</span></h2>
+ <div class="panel" id="sankey"></div>
+ </div>
+ <div>
+ <h2>New advertisers by episode <span class="muted" style="text-transform:none;font-weight:400">— acquisition</span></h2>
+ <div class="panel" id="acq"></div>
+ </div>
+ </div>
</main>
<script>
@@ -171,6 +182,8 @@ async function boot(){
renderOverlap(airings);
renderTenure(airings,rv);
renderNetwork(airings,guests);
+ renderSankey(advertisers,airings);
+ renderAcq(airings,rv);
}
// ── LIQUID FILL GAUGES (animated wave) ──
@@ -483,6 +496,57 @@ function renderKpis(clients,airings,guests){
$("#asof").textContent="— RENTV CRE Talk advertiser report · generated "+d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric'});
}
+// ── AD-SPEND FLOW SANKEY (category → sponsor) ──
+function renderSankey(advertisers,airings){
+ const catOf={};advertisers.forEach(a=>catOf[a.name]=(a.category||"other").split(/[|,/]/)[0].trim());
+ const w={};airings.forEach(a=>{const s=a.advertiser;w[s]=(w[s]||0)+1;});
+ const spon=Object.entries(w).sort((x,y)=>y[1]-x[1]).slice(0,10).map(([n,v])=>({n,v,cat:catOf[n]||"other"}));
+ if(!spon.length){$("#sankey").innerHTML='<div class="muted">No flow.</div>';return;}
+ const cats={};spon.forEach(s=>cats[s.cat]=(cats[s.cat]||0)+s.v);
+ const catList=Object.entries(cats).sort((a,b)=>b[1]-a[1]);
+ const total=spon.reduce((s,x)=>s+x.v,0)||1;
+ const W=520,H=Math.max(300,spon.length*30+20),padX=8,gap=4,colW=14,lx=padX+70,rx=W-padX-70;
+ let cy=10,catY={};catList.forEach(([c,v])=>{const h=v/total*(H-20);catY[c]={y0:cy,y1:cy+h,cur:cy};cy+=h+gap;});
+ let sy=10;const sPos=spon.map(s=>{const h=s.v/total*(H-20);const o={...s,y0:sy,y1:sy+h};sy+=h+gap;return o;});
+ const COL={lender:"#4c8bf5",developer:"#2dd4bf","developer|broker":"#2dd4bf",broker:"#a78bfa",other:"#d4af37"};
+ let svg=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="max-width:100%">`;
+ // ribbons
+ sPos.forEach(s=>{const cy=catY[s.cat];const th=s.y1-s.y0;const cy0=cy.cur;cy.cur+=th;const col=COL[s.cat]||"#d4af37";
+ const x0=lx+colW,x1=rx;const my=(x0+x1)/2;
+ svg+=`<path d="M${x0},${cy0} C${my},${cy0} ${my},${s.y0} ${x1},${s.y0} L${x1},${s.y1} C${my},${s.y1} ${my},${cy0+th} ${x0},${cy0+th} Z" fill="${col}" opacity=".22"></path>`;});
+ // category nodes (left)
+ catList.forEach(([c])=>{const p=catY[c];const col=COL[c]||"#d4af37";
+ svg+=`<rect x="${lx}" y="${p.y0}" width="${colW}" height="${p.y1-p.y0}" fill="${col}" rx="2"></rect>`+
+ `<text x="${lx-6}" y="${(p.y0+p.y1)/2+3}" fill="var(--ink)" font-size="11" text-anchor="end">${esc(c)}</text>`;});
+ // sponsor nodes (right)
+ sPos.forEach(s=>{const col=COL[s.cat]||"#d4af37";
+ svg+=`<rect x="${rx}" y="${s.y0}" width="${colW}" height="${s.y1-s.y0}" fill="${col}" rx="2"></rect>`+
+ `<text x="${rx+colW+6}" y="${(s.y0+s.y1)/2+3}" fill="var(--ink)" font-size="11">${esc(s.n.length>20?s.n.slice(0,19)+"…":s.n)} <tspan fill="var(--dim)">${s.v}</tspan></text>`;});
+ svg+="</svg>";
+ $("#sankey").innerHTML=svg+'<div class="muted" style="margin-top:6px">Ribbon width ∝ ad airings; grouped by sponsor category.</div>';
+}
+
+// ── NEW ADVERTISERS BY EPISODE (acquisition) ──
+function renderAcq(airings,rv){
+ const meta={};Object.entries(rv||{}).forEach(([vid,r])=>{if(r.ep!=null)meta[vid]=r.ep;});
+ const firstEp={};airings.forEach(a=>{const e=meta[a.episode_id];if(e==null)return;if(firstEp[a.advertiser]==null||e<firstEp[a.advertiser])firstEp[a.advertiser]=e;});
+ const cnt={};Object.values(firstEp).forEach(e=>cnt[e]=(cnt[e]||0)+1);
+ const eps=Object.keys(cnt).map(Number).sort((a,b)=>a-b);
+ if(!eps.length){$("#acq").innerHTML='<div class="muted">Acquisition appears once episodes are mapped.</div>';return;}
+ const minE=eps[0],maxE=eps[eps.length-1],W=520,H=260,padL=28,padB=24,padT=10;
+ const bw=(W-padL-10)/(maxE-minE+1), max=Math.max(...Object.values(cnt),1);
+ let cum=0,cumPts=[],bars="";
+ for(let e=minE;e<=maxE;e++){const n=cnt[e]||0;cum+=n;const x=padL+(e-minE)*bw;const h=(n/max)*(H-padT-padB);
+ bars+=`<rect x="${x+1}" y="${H-padB-h}" width="${bw-2}" height="${h}" fill="#2dd4bf" opacity=".8" rx="2"><title>Ep #${e}: ${n} new</title></rect>`;
+ cumPts.push([x+bw/2, (H-padB) - (cum/Object.keys(firstEp).length)*(H-padT-padB)]);}
+ const line=cumPts.map((p,i)=>(i?"L":"M")+p[0].toFixed(0)+","+p[1].toFixed(0)).join(" ");
+ $("#acq").innerHTML=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="max-width:100%">
+ ${bars}<path d="${line}" fill="none" stroke="#d4af37" stroke-width="2" opacity=".85"></path>
+ <text x="${padL}" y="${H-6}" fill="#9aa7b4" font-size="10">Ep #${minE}</text>
+ <text x="${W-10}" y="${H-6}" fill="#9aa7b4" font-size="10" text-anchor="end">Ep #${maxE}</text>
+ </svg><div class="muted">Teal bars = advertisers acquired that episode · gold line = cumulative roster growth.</div>`;
+}
+
// ── SPONSOR TENURE GANTT (first→last episode per client) ──
function renderTenure(airings,rv){
const meta={};Object.entries(rv||{}).forEach(([vid,r])=>{if(r.ep!=null)meta[vid]=r.ep;});
← e1a34a2 5x: sweep logs + REPORT (favicon 404 fixed, clean twice)
·
back to Rentv Ad Engine
·
add Analytics as an in-page tab in the directory (iframe of 8993674 →