← back to Rentv Ad Engine
add Client Analytics page (charts.html): animated liquid-fill share-of-voice gauges + clients-by-airings bars + category donut + airings-over-run timeline; linked from header (Steve-directed)
b873f8f765385ee71811b17c08aa8a4e2f000601 · 2026-08-07 11:18:35 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A public/charts.htmlM public/index.html
Diff
commit b873f8f765385ee71811b17c08aa8a4e2f000601
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 11:18:35 2026 -0700
add Client Analytics page (charts.html): animated liquid-fill share-of-voice gauges + clients-by-airings bars + category donut + airings-over-run timeline; linked from header (Steve-directed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/charts.html | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 1 +
2 files changed, 203 insertions(+)
diff --git a/public/charts.html b/public/charts.html
new file mode 100644
index 0000000..41e9103
--- /dev/null
+++ b/public/charts.html
@@ -0,0 +1,202 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RENTV Ad Engine — Client Analytics</title>
+<style>
+ :root{--bg:#0e1116;--panel:#161b22;--line:#2a3038;--ink:#e6edf3;--dim:#9aa7b4;--teal:#2dd4bf;--gold:#d4af37;--accent:#4c8bf5}
+ *{box-sizing:border-box}
+ body{margin:0;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
+ a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
+ header{position:sticky;top:0;z-index:20;background:linear-gradient(180deg,#11161d,#0e1116);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+ .brand{font-weight:800;letter-spacing:.5px}.brand b{color:var(--gold)}
+ .stat{color:var(--dim);font-size:12px}.stat b{color:var(--ink)}
+ main{padding:20px;max-width:1280px;margin:0 auto}
+ h2{font-size:15px;margin:26px 0 12px;color:var(--dim);font-weight:600;letter-spacing:.4px;text-transform:uppercase}
+ .gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px}
+ .gauge{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;text-align:center}
+ .gauge canvas{width:150px;height:150px;max-width:100%}
+ .gauge .nm{font-weight:700;margin-top:6px;font-size:14px}
+ .gauge .sub{color:var(--dim);font-size:12px}
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+ @media(max-width:820px){.two{grid-template-columns:1fr}}
+ .bar-row{display:flex;align-items:center;gap:10px;margin:7px 0;font-size:13px}
+ .bar-lab{width:200px;flex:0 0 200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}
+ .bar-lab a{color:var(--ink)}
+ .bar-track{flex:1;background:#0e1116;border-radius:6px;overflow:hidden;height:20px;position:relative;border:1px solid var(--line)}
+ .bar-fill{height:100%;background:linear-gradient(90deg,var(--teal),var(--accent));border-radius:6px 0 0 6px;transition:width 1s cubic-bezier(.2,.8,.2,1)}
+ .bar-val{width:52px;flex:0 0 52px;text-align:right;color:var(--dim)}
+ .legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;font-size:12px}
+ .legend span{display:flex;align-items:center;gap:5px;color:var(--dim)}
+ .dot{width:10px;height:10px;border-radius:3px;display:inline-block}
+ svg{display:block;max-width:100%}
+ .muted{color:var(--dim);font-size:12px}
+</style>
+</head>
+<body>
+<header>
+ <div class="brand">RENTV <b>Ad Engine</b> · Client Analytics</div>
+ <div class="stat" id="stat"></div>
+ <div style="margin-left:auto;display:flex;gap:10px;font-size:12px">
+ <a href="./">← Directory</a>
+ <a href="https://www.rentvreview.com/Producers/View/618" target="_blank" rel="noopener">🎬 CRE Talk episodes</a>
+ </div>
+</header>
+
+<main>
+ <h2>Share of voice — top clients (liquid gauges)</h2>
+ <div class="muted" style="margin:-6px 0 10px">Each gauge fills to the client's share of all detected ad airings. Live wave = active sponsor.</div>
+ <div class="gauges" id="gauges"></div>
+
+ <h2>Clients by ad airings</h2>
+ <div class="panel" id="bars"></div>
+
+ <div class="two" style="margin-top:8px">
+ <div>
+ <h2>Category mix</h2>
+ <div class="panel" id="donut"></div>
+ </div>
+ <div>
+ <h2>Ad airings over the show's run</h2>
+ <div class="panel" id="timeline"></div>
+ </div>
+ </div>
+</main>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>(s==null?"":String(s)).replace(/[&<>"]/g,m=>({"&":"&","<":"<",">":">",'"':"""}[m]));
+const PAL=["#2dd4bf","#4c8bf5","#d4af37","#e5989b","#a78bfa","#f59e0b","#34d399","#f472b6","#60a5fa","#facc15"];
+
+async function boot(){
+ const [adv,air,rv]=await Promise.all([
+ fetch("api/advertisers").then(r=>r.json()).catch(()=>({advertisers:[]})),
+ fetch("api/adairings").then(r=>r.json()).catch(()=>({airings:[]})),
+ fetch("api/rentvreview").then(r=>r.json()).catch(()=>({}))
+ ]);
+ const advertisers=adv.advertisers||[], airings=air.airings||[];
+
+ // aggregate airings per client (fallback to appearances when no airings)
+ const byClient={};
+ airings.forEach(a=>byClient[a.advertiser]=(byClient[a.advertiser]||0)+1);
+ let clients=advertisers.map(a=>({name:a.name,category:a.category||"other",
+ airings:byClient[a.name]||0, appearances:a.appearances||0,
+ verified:!!a.review_verified}))
+ .filter(c=>c.airings>0||c.appearances>=2);
+ clients.sort((x,y)=>(y.airings-x.airings)||(y.appearances-x.appearances));
+ const totalAir=clients.reduce((s,c)=>s+c.airings,0)||1;
+
+ $("#stat").innerHTML=`<b>${clients.length}</b> advertising clients · <b>${airings.length}</b> ad airings · <b>${advertisers.length}</b> advertisers total`;
+
+ renderGauges(clients.slice(0,6),totalAir);
+ renderBars(clients.slice(0,14),clients[0]?.airings||1);
+ renderDonut(clients);
+ renderTimeline(airings,rv);
+}
+
+// ── LIQUID FILL GAUGES (animated wave) ──
+function renderGauges(top,total){
+ const wrap=$("#gauges"); wrap.innerHTML="";
+ top.forEach((c,i)=>{
+ const pct=Math.max(0.02,c.airings/total);
+ const col=PAL[i%PAL.length];
+ const box=document.createElement("div"); box.className="gauge";
+ box.innerHTML=`<canvas width="300" height="300"></canvas>
+ <div class="nm" title="${esc(c.name)}">${esc(c.name.length>22?c.name.slice(0,21)+"…":c.name)}</div>
+ <div class="sub">${c.airings} airings · ${Math.round(pct*100)}% share${c.verified?' · <span style="color:var(--gold)">✓</span>':''}</div>`;
+ wrap.appendChild(box);
+ liquid(box.querySelector("canvas"),pct,col,i*400);
+ });
+}
+function liquid(cv,pct,color,phase0){
+ const ctx=cv.getContext("2d"), W=cv.width,H=cv.height,R=W/2-10,cx=W/2,cy=H/2;
+ let t=phase0/1000, fill=0;
+ (function frame(){
+ t+=0.03; fill+=(pct-fill)*0.05; // ease the fill up on load
+ ctx.clearRect(0,0,W,H);
+ // outer ring
+ ctx.beginPath();ctx.arc(cx,cy,R,0,Math.PI*2);ctx.strokeStyle=color;ctx.globalAlpha=.35;ctx.lineWidth=6;ctx.stroke();ctx.globalAlpha=1;
+ // clip to circle
+ ctx.save();ctx.beginPath();ctx.arc(cx,cy,R-6,0,Math.PI*2);ctx.clip();
+ const level=cy+R-6-(fill*(2*(R-6))); // y of water line
+ const amp=8+6*Math.min(1,pct*2), len=W;
+ ctx.beginPath();ctx.moveTo(0,H);
+ for(let x=0;x<=len;x++){const y=level+Math.sin((x/len*4*Math.PI)+t)*amp+Math.sin((x/len*2*Math.PI)-t*1.3)*amp*0.5;ctx.lineTo(x,y);}
+ ctx.lineTo(W,H);ctx.closePath();
+ const g=ctx.createLinearGradient(0,level-amp,0,H);g.addColorStop(0,color);g.addColorStop(1,color+"aa");
+ ctx.fillStyle=g;ctx.globalAlpha=.85;ctx.fill();ctx.globalAlpha=1;
+ ctx.restore();
+ // % text
+ ctx.fillStyle="#e6edf3";ctx.font="700 46px -apple-system,Segoe UI,Roboto";ctx.textAlign="center";ctx.textBaseline="middle";
+ ctx.fillText(Math.round(fill*100)+"%",cx,cy);
+ requestAnimationFrame(frame);
+ })();
+}
+
+// ── HORIZONTAL BARS ──
+function renderBars(rows,max){
+ const el=$("#bars"); el.innerHTML="";
+ rows.forEach((c,i)=>{
+ const r=document.createElement("div"); r.className="bar-row";
+ r.innerHTML=`<div class="bar-lab"><a href="./?q=${encodeURIComponent(c.name)}" title="${esc(c.name)}">${esc(c.name)}</a></div>
+ <div class="bar-track"><div class="bar-fill" style="width:0"></div></div>
+ <div class="bar-val">${c.airings}</div>`;
+ el.appendChild(r);
+ requestAnimationFrame(()=>{r.querySelector(".bar-fill").style.width=(6+94*c.airings/max)+"%";
+ r.querySelector(".bar-fill").style.background=`linear-gradient(90deg,${PAL[i%PAL.length]},${PAL[(i+3)%PAL.length]})`;});
+ });
+}
+
+// ── DONUT (category mix) ──
+function renderDonut(clients){
+ const cat={};
+ clients.forEach(c=>{const k=(c.category||"other").split(/[|,/]/)[0].trim();cat[k]=(cat[k]||0)+(c.airings||1);});
+ const entries=Object.entries(cat).sort((a,b)=>b[1]-a[1]);
+ const total=entries.reduce((s,e)=>s+e[1],0)||1;
+ const cx=110,cy=110,r=90,rin=52;let a0=-Math.PI/2,segs="";
+ entries.forEach(([k,v],i)=>{
+ const a1=a0+v/total*Math.PI*2;
+ const x0=cx+r*Math.cos(a0),y0=cy+r*Math.sin(a0),x1=cx+r*Math.cos(a1),y1=cy+r*Math.sin(a1);
+ const xi1=cx+rin*Math.cos(a1),yi1=cy+rin*Math.sin(a1),xi0=cx+rin*Math.cos(a0),yi0=cy+rin*Math.sin(a0);
+ const big=a1-a0>Math.PI?1:0;
+ segs+=`<path d="M${x0},${y0} A${r},${r} 0 ${big} 1 ${x1},${y1} L${xi1},${yi1} A${rin},${rin} 0 ${big} 0 ${xi0},${yi0} Z" fill="${PAL[i%PAL.length]}" opacity=".9"></path>`;
+ a0=a1;
+ });
+ $("#donut").innerHTML=`<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
+ <svg width="220" height="220" viewBox="0 0 220 220">${segs}
+ <text x="110" y="104" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700">${entries.length}</text>
+ <text x="110" y="126" text-anchor="middle" fill="#9aa7b4" font-size="11">categories</text></svg>
+ <div class="legend" style="flex-direction:column;align-items:flex-start">
+ ${entries.map(([k,v],i)=>`<span><i class="dot" style="background:${PAL[i%PAL.length]}"></i>${esc(k)} · ${Math.round(v/total*100)}%</span>`).join("")}
+ </div></div>`;
+}
+
+// ── TIMELINE (airings per episode across the run) ──
+function renderTimeline(airings,rv){
+ // map episode_id -> {ep#, date} from rentvreview; count airings per episode
+ const meta={};Object.entries(rv||{}).forEach(([vid,r])=>meta[vid]={ep:r.ep,date:r.date});
+ const cnt={};airings.forEach(a=>cnt[a.episode_id]=(cnt[a.episode_id]||0)+1);
+ let pts=Object.entries(cnt).map(([vid,n])=>({ep:meta[vid]?.ep??null,date:meta[vid]?.date||"",n})).filter(p=>p.ep!=null);
+ pts.sort((a,b)=>a.ep-b.ep);
+ if(!pts.length){$("#timeline").innerHTML='<div class="muted">Episode-dated airings appear here once The REview mapping is complete.</div>';return;}
+ const W=520,H=200,pad=30,max=Math.max(...pts.map(p=>p.n),1);
+ const x=i=>pad+i*(W-2*pad)/Math.max(1,pts.length-1), y=v=>H-pad-(v/max)*(H-2*pad);
+ let area=`M${x(0)},${H-pad}`, line="";
+ pts.forEach((p,i)=>{area+=` L${x(i)},${y(p.n)}`;line+=(i?" L":"M")+x(i)+","+y(p.n);});
+ area+=` L${x(pts.length-1)},${H-pad} Z`;
+ const dots=pts.map((p,i)=>`<circle cx="${x(i)}" cy="${y(p.n)}" r="3" fill="#2dd4bf"><title>Ep #${p.ep}${p.date?" · "+p.date:""} — ${p.n} airings</title></circle>`).join("");
+ $("#timeline").innerHTML=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
+ <defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2dd4bf" stop-opacity=".45"/><stop offset="1" stop-color="#2dd4bf" stop-opacity="0"/></linearGradient></defs>
+ <path d="${area}" fill="url(#ag)"></path>
+ <path d="${line}" fill="none" stroke="#2dd4bf" stroke-width="2"></path>
+ ${dots}
+ <text x="${pad}" y="${H-8}" fill="#9aa7b4" font-size="10">Ep #${pts[0].ep}</text>
+ <text x="${W-pad}" y="${H-8}" fill="#9aa7b4" font-size="10" text-anchor="end">Ep #${pts[pts.length-1].ep}</text>
+ </svg><div class="muted">Detected ad airings per episode (from transcript timing), ordered by episode number.</div>`;
+}
+boot();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index ddcbc7c..7e0eca0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -72,6 +72,7 @@
<a href="https://soundcloud.com/rentvnews" target="_blank" rel="noopener" title="RENTV podcast on SoundCloud">🎧 Podcast</a>
<a href="https://www.rentvreview.com" target="_blank" rel="noopener" title="The REview — RENTV's authoritative episode archive">📺 The REview</a>
<a href="https://www.rentvreview.com/Producers/View/618" target="_blank" rel="noopener" title="RENTV CRE Talk — all episodes + official sponsors on The REview">🎬 CRE Talk episodes</a>
+ <a href="charts.html" title="Client analytics — graphs + liquid charts">📊 Analytics</a>
</div>
</header>
← 2ed2dbe auto-data-snapshot: 2026-08-07T11:14:36 (8 data files) — dat
·
back to Rentv Ad Engine
·
analytics page: add per-client trend sparklines (rising/easi d81a056 →