← back to Rentv Ad Engine
public/charts.html
656 lines
<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<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}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:4px 0 8px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
.kpi .v{font-size:26px;font-weight:800;color:var(--ink);line-height:1.1}
.kpi .v.gold{color:var(--gold)}.kpi .v.teal{color:var(--teal)}
.kpi .k{color:var(--dim);font-size:12px;margin-top:4px}
.prbtn{background:var(--teal);color:#08131a;border:0;border-radius:8px;padding:6px 12px;font-weight:700;cursor:pointer;font-size:12px}
@media print{
header{position:static}.prbtn,#rate,.gauges canvas{display:none!important}
body{background:#fff;color:#111}.panel,.kpi,.gauge{background:#fff;border-color:#ccc}
.kpi .v{color:#111}.muted{color:#555}h2{color:#333}
main{max-width:100%}
}
</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>
<button class="prbtn" onclick="window.print()">🖨 Print / Save PDF</button>
</div>
</header>
<main>
<h2 style="margin-top:8px">Executive summary <span class="muted" id="asof" style="text-transform:none;font-weight:400"></span></h2>
<div class="kpis" id="kpis"></div>
<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>
<h2>Per-client trend <span class="muted" style="text-transform:none;font-weight:400">— airings per episode across the run</span></h2>
<div class="gauges" id="trends" style="grid-template-columns:repeat(auto-fill,minmax(240px,1fr))"></div>
<h2>Estimated sponsorship value
<span class="muted" style="text-transform:none;font-weight:400">— model, not RENTV's rate card ·
$<input id="rate" type="number" value="750" min="0" step="50" style="width:74px;background:#0e1116;border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 6px"> / episode sponsored</span></h2>
<div class="panel" id="revenue"></div>
<h2>Guest analytics</h2>
<div class="two">
<div class="panel" id="guestSplit"></div>
<div class="panel" id="guestCos"></div>
</div>
<h2>Guest topics <span class="muted" style="text-transform:none;font-weight:400">— what guests talked about (size = frequency)</span></h2>
<div class="panel" id="topics"></div>
<h2>Ad airing history
<span class="muted" style="text-transform:none;font-weight:400">— every detected spot; click ▶ to jump, click a header to sort</span></h2>
<div class="panel">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<input id="tq" placeholder="Search airings (space = AND)…" style="flex:1;min-width:220px;background:#0e1116;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 10px">
<span id="cols" style="display:flex;gap:6px;flex-wrap:wrap"></span>
<a id="csv" style="cursor:pointer">⬇ CSV</a>
<a id="reset" style="cursor:pointer">Reset</a>
</div>
<div style="max-height:520px;overflow:auto"><table id="airtbl"></table></div>
</div>
<h2>Per-client airing history <span class="muted" style="text-transform:none;font-weight:400">— download one client's spots as CSV</span></h2>
<div class="panel" id="perclient"></div>
<div class="two">
<div>
<h2>Guest location map</h2>
<div class="panel" id="gmap"></div>
</div>
<div>
<h2>Sponsor-overlap matrix <span class="muted" style="text-transform:none;font-weight:400">— shared episodes</span></h2>
<div class="panel" id="overlap"></div>
</div>
</div>
<h2>Sponsor tenure <span class="muted" style="text-transform:none;font-weight:400">— first → last episode each client sponsored · teal = still active, amber = lapsed</span></h2>
<div class="panel" id="tenure"></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>
<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>
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,gst]=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(()=>({})),
fetch("api/guests").then(r=>r.json()).catch(()=>({guests:[]}))
]);
const advertisers=adv.advertisers||[], airings=air.airings||[], guests=gst.guests||[];
window.__RV=rv;
// 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`;
renderKpis(clients,airings,guests);
renderGauges(clients.slice(0,6),totalAir);
renderBars(clients.slice(0,14),clients[0]?.airings||1);
renderDonut(clients);
renderTimeline(airings,rv);
renderTrends(clients.slice(0,8),airings,rv);
renderRevenue(clients,airings);
renderGuests(guests);
renderTopics(guests);
renderAiringTable(airings,rv);
renderPerClient(clients,airings);
renderGuestMap(guests);
renderOverlap(airings);
renderTenure(airings,rv);
renderNetwork(airings,guests);
renderSankey(advertisers,airings);
renderAcq(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>`;
}
// ── PER-CLIENT TREND (sparkline of airings per episode across the run) ──
function renderTrends(top,airings,rv){
const meta={};Object.entries(rv||{}).forEach(([vid,r])=>{if(r.ep!=null)meta[vid]=r.ep;});
const eps=[...new Set(Object.values(meta))].sort((a,b)=>a-b); // ordered episode #s
const el=$("#trends"); el.innerHTML="";
if(!eps.length){el.innerHTML='<div class="muted">Trend appears once episode dates are mapped.</div>';return;}
const idx={};eps.forEach((e,i)=>idx[e]=i);
top.forEach((c,ci)=>{
const series=eps.map(()=>0);
airings.filter(a=>a.advertiser===c.name).forEach(a=>{const e=meta[a.episode_id];if(e!=null)series[idx[e]]++;});
const W=220,H=54,max=Math.max(...series,1);
const x=i=>4+i*(W-8)/Math.max(1,series.length-1), y=v=>H-6-(v/max)*(H-14);
const col=PAL[ci%PAL.length];
let line="",area=`M${x(0)},${H-6}`;
series.forEach((v,i)=>{line+=(i?" L":"M")+x(i)+","+y(v);area+=` L${x(i)},${y(v)}`;});
area+=` L${x(series.length-1)},${H-6} Z`;
const recent=series.slice(-3).reduce((s,v)=>s+v,0),early=series.slice(0,3).reduce((s,v)=>s+v,0);
const trend=recent>early?'▲ rising':recent<early?'▼ easing':'▬ steady';
const box=document.createElement("div"); box.className="gauge"; box.style.textAlign="left"; box.style.padding="12px";
box.innerHTML=`<div class="nm" style="margin:0" title="${esc(c.name)}">${esc(c.name.length>26?c.name.slice(0,25)+"…":c.name)}</div>
<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="margin:4px 0">
<path d="${area}" fill="${col}" opacity=".18"></path>
<path d="${line}" fill="none" stroke="${col}" stroke-width="2"></path></svg>
<div class="sub" style="text-align:left">${c.airings} airings · <span style="color:${recent>=early?'var(--teal)':'var(--dim)'}">${trend}</span></div>`;
el.appendChild(box);
});
}
// ── ESTIMATED SPONSORSHIP VALUE (transparent model; adjustable rate) ──
let __REVDATA=[];
function renderRevenue(clients,airings){
const epsBy={};airings.forEach(a=>{(epsBy[a.advertiser]=epsBy[a.advertiser]||new Set()).add(a.episode_id);});
__REVDATA=clients.map(c=>({name:c.name,eps:(epsBy[c.name]||new Set()).size})).filter(c=>c.eps>0).sort((a,b)=>b.eps-a.eps);
const draw=()=>{
const rate=Math.max(0,+($("#rate").value||0));
const rows=__REVDATA.map(c=>({...c,val:c.eps*rate}));
const max=Math.max(...rows.map(r=>r.val),1), total=rows.reduce((s,r)=>s+r.val,0);
$("#revenue").innerHTML=`<div style="font-size:20px;font-weight:800;margin-bottom:10px">Est. portfolio value: <span style="color:var(--gold)">$${total.toLocaleString()}</span> <span class="muted" style="font-size:12px;font-weight:400">across ${rows.length} clients (episodes-sponsored × rate)</span></div>`+
rows.slice(0,14).map((r,i)=>`<div class="bar-row"><div class="bar-lab" title="${esc(r.name)}">${esc(r.name)}</div>
<div class="bar-track"><div class="bar-fill" style="width:${6+94*r.val/max}%;background:linear-gradient(90deg,var(--gold),#b8860b)"></div></div>
<div class="bar-val" style="width:80px;flex:0 0 80px">$${r.val.toLocaleString()}</div></div>`).join("");
};
draw(); $("#rate").oninput=draw;
}
// ── GUEST ANALYTICS ──
function renderGuests(guests){
const iv=guests.filter(g=>!g.is_panel).length, pn=guests.filter(g=>g.is_panel).length;
const ver=guests.filter(g=>g.verified).length;
const total=guests.length||1, ivPct=Math.round(iv/total*100);
$("#guestSplit").innerHTML=`<div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap">
<svg width="150" height="150" viewBox="0 0 150 150">
<circle cx="75" cy="75" r="60" fill="none" stroke="#2a3038" stroke-width="16"></circle>
<circle cx="75" cy="75" r="60" fill="none" stroke="var(--teal)" stroke-width="16"
stroke-dasharray="${(ivPct/100*377).toFixed(0)} 377" transform="rotate(-90 75 75)" stroke-linecap="round"></circle>
<text x="75" y="70" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700">${total}</text>
<text x="75" y="90" text-anchor="middle" fill="#9aa7b4" font-size="11">guests</text></svg>
<div class="legend" style="flex-direction:column;align-items:flex-start;font-size:13px">
<span><i class="dot" style="background:var(--teal)"></i>Interview guests · <b style="color:var(--ink)">${iv}</b></span>
<span><i class="dot" style="background:#2a3038"></i>Panelists · <b style="color:var(--ink)">${pn}</b></span>
<span><i class="dot" style="background:var(--gold)"></i>Identity-verified · <b style="color:var(--ink)">${ver}</b></span>
</div></div>`;
// top companies represented
const co={};guests.forEach(g=>{const k=(g.company||"").trim();if(k&&k.length>2)co[k]=(co[k]||0)+1;});
const top=Object.entries(co).sort((a,b)=>b[1]-a[1]).slice(0,10);
const max=Math.max(...top.map(t=>t[1]),1);
$("#guestCos").innerHTML=`<div class="muted" style="margin-bottom:8px">Companies represented by guests (top ${top.length})</div>`+
top.map(([k,v],i)=>`<div class="bar-row"><div class="bar-lab" title="${esc(k)}">${esc(k)}</div>
<div class="bar-track"><div class="bar-fill" style="width:${8+92*v/max}%;background:linear-gradient(90deg,${PAL[i%PAL.length]},${PAL[(i+2)%PAL.length]})"></div></div>
<div class="bar-val">${v}</div></div>`).join("");
}
// ── GUEST TOPICS word-frequency cloud ──
function renderTopics(guests){
const freq={};
guests.forEach(g=>(g.topics||[]).forEach(t=>{const k=String(t).trim().toLowerCase();if(k.length>2)freq[k]=(freq[k]||0)+1;}));
const items=Object.entries(freq).sort((a,b)=>b[1]-a[1]).slice(0,60);
if(!items.length){$("#topics").innerHTML='<div class="muted">No guest topics extracted.</div>';return;}
const max=Math.max(...items.map(i=>i[1])),min=Math.min(...items.map(i=>i[1]));
const el=$("#topics"); el.innerHTML='<div style="display:flex;flex-wrap:wrap;gap:8px 14px;align-items:baseline;line-height:1.7"></div>';
const box=el.firstChild;
items.forEach(([w,n],i)=>{
const sz=13+((n-min)/((max-min)||1))*26;
const s=document.createElement("span");
s.textContent=w; s.title=`${n} guest${n>1?"s":""}`;
s.style.cssText=`font-size:${sz.toFixed(0)}px;color:${PAL[i%PAL.length]};font-weight:${sz>26?700:500};cursor:default`;
box.appendChild(s);
});
}
// ── AD AIRING HISTORY table (search-all + sortable + toggle cols + CSV + reset) ──
function renderAiringTable(airings,rv){
const meta={};Object.entries(rv||{}).forEach(([vid,r])=>{if(r.ep!=null)meta[vid]=r.ep;});
const rows=airings.map(a=>({time:a.timecode,start:a.start,client:a.advertiser,
episode:(a.episode_title||"").replace(/RENTV |CRE Talk /g,"").slice(0,34),ep:meta[a.episode_id]??9999,
copy:(a.spoken_copy||"").slice(0,140),deeplink:a.deeplink}));
const COLS=[["time","Time"],["client","Client"],["episode","Episode"],["copy","Spoken copy"]];
const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem("rae_air_"+k))??d}catch{return d}};
const setLS=(k,v)=>localStorage.setItem("rae_air_"+k,JSON.stringify(v));
let sort=LS("sort",{key:"episode",dir:1}), vis=LS("vis",{time:1,client:1,episode:1,copy:1});
const esc2=s=>(s==null?"":String(s)).replace(/[&<>"]/g,m=>({"&":"&","<":"<",">":">",'"':"""}[m]));
function draw(){
const toks=$("#tq").value.toLowerCase().split(/\s+/).filter(Boolean);
let L=rows.filter(r=>{const h=(r.client+" "+r.episode+" "+r.copy+" "+r.time).toLowerCase();return toks.every(t=>h.includes(t));});
const k=sort.key;
L.sort((a,b)=>{let x=a[k],y=b[k];
if(k==="time"){x=a.start;y=b.start}if(k==="episode"){x=a.ep;y=b.ep}
return (x>y?1:x<y?-1:0)*sort.dir;});
const cols=COLS.filter(c=>vis[c[0]]);
const th=cols.map(([kk,lab])=>`<th data-k="${kk}" style="position:sticky;top:0;background:var(--panel);cursor:pointer;padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap">${lab}${sort.key===kk?(sort.dir>0?" ▲":" ▼"):""}</th>`).join("");
const body=L.map(r=>`<tr>${cols.map(([kk])=>{
if(kk==="time")return `<td style="padding:6px 10px;border-bottom:1px solid var(--line)"><a href="${esc2(r.deeplink)}" target="_blank" rel="noopener" class="mailbtn" style="text-decoration:none;padding:3px 8px">▶ ${esc2(r.time)}</a></td>`;
if(kk==="copy")return `<td style="padding:6px 10px;border-bottom:1px solid var(--line);color:var(--dim);max-width:520px">${esc2(r[kk])}</td>`;
return `<td style="padding:6px 10px;border-bottom:1px solid var(--line)">${esc2(r[kk])}</td>`;}).join("")}</tr>`).join("");
$("#airtbl").innerHTML=`<thead><tr>${th}</tr></thead><tbody>${body||'<tr><td style="padding:14px;color:var(--dim)">No matches</td></tr>'}</tbody>`;
$("#airtbl").querySelectorAll("th").forEach(el=>el.onclick=()=>{const kk=el.dataset.k;sort=(sort.key===kk)?{key:kk,dir:-sort.dir}:{key:kk,dir:1};setLS("sort",sort);draw();});
$("#cols").innerHTML=COLS.map(([kk,lab])=>`<label style="font-size:12px;color:var(--dim);display:inline-flex;gap:4px;align-items:center"><input type="checkbox" data-k="${kk}" ${vis[kk]?"checked":""}>${lab}</label>`).join("");
$("#cols").querySelectorAll("input").forEach(cb=>cb.onchange=()=>{vis[cb.dataset.k]=cb.checked?1:0;setLS("vis",vis);draw();});
$("#csv").onclick=()=>{const cs=COLS.filter(c=>vis[c[0]]);const head=cs.map(c=>c[1]).join(",");
const lines=L.map(r=>cs.map(([kk])=>`"${String(kk==="time"?r.time:r[kk]).replace(/"/g,'""')}"`).join(","));
const blob=new Blob([head+"\n"+lines.join("\n")],{type:"text/csv"});const u=URL.createObjectURL(blob);
const a=document.createElement("a");a.href=u;a.download="rentv-ad-airings.csv";a.click();URL.revokeObjectURL(u);};
}
$("#tq").value=LS("q","");$("#tq").oninput=()=>{setLS("q",$("#tq").value);draw();};
$("#reset").onclick=()=>{["sort","vis","q"].forEach(k=>localStorage.removeItem("rae_air_"+k));sort={key:"episode",dir:1};vis={time:1,client:1,episode:1,copy:1};$("#tq").value="";draw();};
draw();
}
// ── PER-CLIENT airing history CSV ──
function renderPerClient(clients,airings){
const byC={};airings.forEach(a=>{(byC[a.advertiser]=byC[a.advertiser]||[]).push(a);});
const list=clients.filter(c=>byC[c.name]).map(c=>({name:c.name,rows:byC[c.name].slice().sort((x,y)=>y.episode_id-x.episode_id||x.start-y.start)}));
const dl=(name,rows)=>{
const head="Episode,Timecode,Deep link,Spoken copy";
const body=rows.map(r=>[r.episode_title,r.timecode,r.deeplink,r.spoken_copy].map(v=>`"${String(v||"").replace(/"/g,'""')}"`).join(",")).join("\n");
const b=new Blob([head+"\n"+body],{type:"text/csv"});const u=URL.createObjectURL(b);
const a=document.createElement("a");a.href=u;a.download=`airings-${name.replace(/[^a-z0-9]+/gi,"-").toLowerCase()}.csv`;a.click();URL.revokeObjectURL(u);
};
$("#perclient").innerHTML=`<div style="display:flex;flex-wrap:wrap;gap:8px">`+
list.map((c,i)=>`<button class="mailbtn" data-i="${i}" style="background:#0e1116;color:var(--ink);border:1px solid var(--line);font-weight:500">⬇ ${esc(c.name)} <span class="muted">(${c.rows.length})</span></button>`).join("")+`</div>`;
$("#perclient").querySelectorAll("button").forEach(b=>b.onclick=()=>{const c=list[+b.dataset.i];dl(c.name,c.rows);});
}
// ── GUEST LOCATION MAP (geographic scatter over a US frame) ──
const GEO=[["san diego",32.72,-117.16],["marina del rey",33.98,-118.45],["newport beach",33.62,-117.93],
["irvine",33.68,-117.83],["long beach",33.77,-118.19],["santa ana",33.75,-117.87],["carlsbad",33.16,-117.35],
["orange county",33.72,-117.83],["inland empire",34.06,-117.65],["ontario",34.06,-117.65],
["los angeles",34.05,-118.24],["hollywood",34.09,-118.33],["san francisco",37.77,-122.42],
["sacramento",38.58,-121.49],["new york",40.71,-74.01],["phoenix",33.45,-112.07],
["bethesda",39.0,-77.1],["maryland",39.0,-77.1],["southern california",34.05,-118.24],["california",36.7,-119.7]];
function geo(loc){const s=(loc||"").toLowerCase();for(const[k,la,lo]of GEO)if(s.includes(k))return[la,lo];return null;}
function renderGuestMap(guests){
const cnt={},unmapped=[];
guests.forEach(g=>{const p=geo(g.location);if(p){const key=p.join(",");(cnt[key]=cnt[key]||{p,n:0,names:[]});cnt[key].n++;cnt[key].names.push(g.name);}else if((g.location||"").trim())unmapped.push(g.location);});
const W=520,H=300,padX=20,padY=20;
const X=lo=>padX+((lo-(-125))/((-66)-(-125)))*(W-2*padX);
const Y=la=>padY+((50-la)/(50-24))*(H-2*padY);
const pts=Object.values(cnt);const max=Math.max(...pts.map(p=>p.n),1);
const dots=pts.map((p,i)=>{const x=X(p.p[1]),y=Y(p.p[0]),r=6+14*Math.sqrt(p.n/max);
return `<circle cx="${x.toFixed(0)}" cy="${y.toFixed(0)}" r="${r.toFixed(0)}" fill="var(--teal)" opacity=".55" stroke="var(--teal)"><title>${esc(p.names.join(", "))} (${p.n})</title></circle>`;}).join("");
$("#gmap").innerHTML=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="background:#0e1116;border-radius:8px">
<rect x="${padX}" y="${padY}" width="${W-2*padX}" height="${H-2*padY}" fill="none" stroke="#2a3038"></rect>
<text x="${X(-119).toFixed(0)}" y="${Y(37).toFixed(0)}" fill="#3a4450" font-size="11">CA</text>
<text x="${X(-74).toFixed(0)}" y="${Y(40.7).toFixed(0)}" fill="#3a4450" font-size="11">NY</text>
${dots}</svg>
<div class="muted" style="margin-top:6px">Guests plotted by where their company is based · bubble = # guests${unmapped.length?` · ${unmapped.length} unplaced`:""}</div>`;
}
// ── SPONSOR-OVERLAP MATRIX (shared episodes heatmap) ──
function renderOverlap(airings){
const epSet={};airings.forEach(a=>{(epSet[a.advertiser]=epSet[a.advertiser]||new Set()).add(a.episode_id);});
const top=Object.entries(epSet).map(([k,v])=>[k,v]).sort((a,b)=>b[1].size-a[1].size).slice(0,8);
if(top.length<2){$("#overlap").innerHTML='<div class="muted">Not enough co-sponsored episodes.</div>';return;}
const shared=(a,b)=>{let n=0;a.forEach(e=>{if(b.has(e))n++;});return n;};
const maxOff=Math.max(1,...top.flatMap(([,a],i)=>top.map(([,b],j)=>i===j?0:shared(a,b))));
const short=n=>n.replace(/ (Group|Companies|Savings Bank|Property Tax Appeal|Real Estate Inspectors|Mortgage Lenders|Partners|Mortgage)$/,"").slice(0,14);
let h='<table style="border-collapse:collapse;font-size:11px"><thead><tr><th></th>'+
top.map(([n])=>`<th style="padding:4px 6px;color:var(--dim);writing-mode:vertical-rl;transform:rotate(180deg);white-space:nowrap;height:80px">${esc(short(n))}</th>`).join("")+'</tr></thead><tbody>';
top.forEach(([rn,ra],i)=>{
h+=`<tr><td style="padding:4px 8px;color:var(--dim);white-space:nowrap;text-align:right">${esc(short(rn))}</td>`;
top.forEach(([,ca],j)=>{
if(i===j){h+=`<td style="width:30px;height:30px;text-align:center;background:#1c2530;color:var(--dim)">${ra.size}</td>`;}
else{const s=shared(ra,ca);const op=s/maxOff;h+=`<td title="${s} shared" style="width:30px;height:30px;text-align:center;background:rgba(45,212,191,${(op*0.85).toFixed(2)});color:${op>0.4?'#08131a':'var(--dim)'}">${s||""}</td>`;}
});
h+='</tr>';
});
h+='</tbody></table><div class="muted" style="margin-top:6px">Diagonal = total episodes sponsored; cell = episodes two sponsors shared.</div>';
$("#overlap").innerHTML=h;
}
// ── EXECUTIVE SUMMARY KPIs ──
function renderKpis(clients,airings,guests){
const eps=new Set(airings.map(a=>a.episode_id));
const epByClient={};airings.forEach(a=>{(epByClient[a.advertiser]=epByClient[a.advertiser]||new Set()).add(a.episode_id);});
const estVal=Object.values(epByClient).reduce((s,set)=>s+set.size,0)*750;
const top=clients[0];
const verified=guests.filter(g=>g.verified).length;
const K=[
["v teal",clients.length,"advertising clients"],
["v",airings.length,"ad airings (as-aired)"],
["v",eps.size,"episodes with ads"],
["v",top?top.name:"—","top client"],
["v gold","$"+estVal.toLocaleString(),"est. value @ $750/ep"],
["v",guests.length+" · "+verified+" ✓","guests (verified)"],
];
$("#kpis").innerHTML=K.map(([cls,v,k])=>`<div class="kpi"><div class="${cls}" style="${String(v).length>14?'font-size:18px':''}">${esc(v)}</div><div class="k">${esc(k)}</div></div>`).join("");
const d=new Date();
$("#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;});
const byC={};airings.forEach(a=>{const e=meta[a.episode_id];if(e!=null)(byC[a.advertiser]=byC[a.advertiser]||new Set()).add(e);});
let rows=Object.entries(byC).map(([name,set])=>{const eps=[...set].sort((a,b)=>a-b);
return {name,eps,first:eps[0],last:eps[eps.length-1],n:eps.length};}).filter(r=>r.n>=1);
if(!rows.length){$("#tenure").innerHTML='<div class="muted">Tenure appears once episode mapping is complete.</div>';return;}
const allEps=rows.flatMap(r=>r.eps),minE=Math.min(...allEps),maxE=Math.max(...allEps);
rows.sort((a,b)=>a.first-b.first||b.n-a.n);
const W=1000,padL=210,padR=30,rowH=26,H=rows.length*rowH+40;
const x=e=>padL+((e-minE)/((maxE-minE)||1))*(W-padL-padR);
const active=r=>r.last>=maxE-1;
let svg=`<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="max-width:100%">`;
// axis ticks
for(let e=minE;e<=maxE;e++){const gx=x(e);svg+=`<line x1="${gx}" y1="24" x2="${gx}" y2="${H-16}" stroke="#1c2530"></line>`;
if((e-minE)%2===0||e===maxE)svg+=`<text x="${gx}" y="16" fill="#3a4450" font-size="9" text-anchor="middle">#${e}</text>`;}
rows.forEach((r,i)=>{
const y=30+i*rowH, col=active(r)?"#2dd4bf":"#d4af37";
svg+=`<text x="${padL-10}" y="${y+9}" fill="var(--ink)" font-size="12" text-anchor="end">${esc(r.name.length>30?r.name.slice(0,29)+"…":r.name)}</text>`;
svg+=`<rect x="${x(r.first)}" y="${y+2}" width="${Math.max(3,x(r.last)-x(r.first))}" height="12" rx="6" fill="${col}" opacity=".28"></rect>`;
r.eps.forEach(e=>{svg+=`<circle cx="${x(e)}" cy="${y+8}" r="4" fill="${col}"><title>Ep #${e}</title></circle>`;});
svg+=`<text x="${W-4}" y="${y+11}" fill="var(--dim)" font-size="11" text-anchor="end">${r.n} ep${r.n>1?"s":""}${active(r)?"":" · lapsed"}</text>`;
});
svg+="</svg>";
const act=rows.filter(active).length;
$("#tenure").innerHTML=`<div class="muted" style="margin-bottom:8px">${rows.length} clients · <span style="color:var(--teal)">${act} active</span> · <span style="color:var(--gold)">${rows.length-act} lapsed</span> (episodes #${minE}–#${maxE})</div>`+svg;
}
// ── 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>
</html>