← back to Agent Ad Network
admin.html
372 lines
<!doctype html>
<html><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"><title>Agent Ad Network — Admin</title>
<style>
:root { --fg:#e6e6e6; --bg:#101216; --card:#181b21; --line:#2a2f38; --accent:#4fd1c5;
--imp:#4fd1c5; --clk:#f472b6; --usr:#a78bfa; }
body { font:14px -apple-system,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--fg); margin:24px; }
h1 { font-size:18px; letter-spacing:.04em; } h1 small { color:#8a93a3; font-weight:400; }
#stats { display:flex; gap:14px; margin:12px 0 14px; flex-wrap:wrap; }
.stat { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px 16px; }
.stat b { display:block; font-size:20px; color:var(--accent); } .stat a { color:inherit; text-decoration:none; }
/* ---- live viz row ---- */
#viz { display:flex; gap:14px; margin:0 0 18px; flex-wrap:wrap; }
#galaxyWrap { position:relative; flex:1.4 1 520px; min-width:380px; height:380px; border:1px solid var(--line);
border-radius:10px; overflow:hidden; background:radial-gradient(ellipse at 50% 40%, #171c26 0%, #0b0d12 70%); }
#galaxy { width:100%; height:100%; display:block; cursor:grab; }
#galaxyTip { position:absolute; pointer-events:none; background:#181b21ee; border:1px solid var(--line);
border-radius:6px; padding:6px 10px; font-size:12px; display:none; max-width:260px; z-index:3; }
#galaxyTip b { color:var(--accent); }
#galaxyLegend { position:absolute; left:10px; bottom:8px; font-size:11px; color:#8a93a3; z-index:2; }
#galaxyLegend i { display:inline-block; width:8px; height:8px; border-radius:99px; margin:0 4px 0 10px; }
#galaxyTitle { position:absolute; left:12px; top:10px; font-size:11px; letter-spacing:.12em; color:#8a93a3; z-index:2; }
#galaxyLive { color:#5ee6a8; animation:blink 2s infinite; } @keyframes blink { 50% { opacity:.25; } }
#charts { flex:1 1 340px; min-width:320px; display:flex; flex-direction:column; gap:14px; }
.chartCard { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:10px 12px 6px; }
.chartCard h3 { margin:0 0 4px; font-size:11px; font-weight:600; letter-spacing:.1em; color:#8a93a3; }
.chartCard h3 span { float:right; font-weight:400; text-transform:none; letter-spacing:0; }
.chartCard canvas { width:100%; height:96px; display:block; }
#donutCard { display:flex; align-items:center; gap:12px; }
#donut { width:96px !important; height:96px !important; flex:0 0 96px; }
#donutLegend { font-size:12px; line-height:1.8; color:#aab2c0; }
#donutLegend i { display:inline-block; width:8px; height:8px; border-radius:99px; margin-right:6px; }
/* ---- controls + table (unchanged spec) ---- */
#controls { display:flex; gap:10px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
input[type=search] { background:var(--card); border:1px solid var(--line); color:var(--fg); padding:7px 10px; border-radius:6px; width:280px; }
.colchip { border:1px solid var(--line); background:var(--card); color:#8a93a3; border-radius:99px; padding:3px 10px; cursor:pointer; font-size:12px; }
.colchip.on { color:var(--accent); border-color:var(--accent); }
button { background:var(--card); border:1px solid var(--line); color:var(--fg); border-radius:6px; padding:6px 12px; cursor:pointer; }
table { border-collapse:collapse; width:100%; background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:hidden; }
th,td { padding:8px 12px; text-align:left; border-bottom:1px solid var(--line); white-space:nowrap; }
th { cursor:pointer; color:#8a93a3; font-weight:600; user-select:none; }
th.sorted { color:var(--accent); }
tr.row:hover { background:#1d212a; cursor:pointer; }
tr.detail td { background:#13161c; white-space:normal; color:#aab2c0; }
a { color:var(--accent); }
.when { color:#8a93a3; font-size:12px; }
.pill { border-radius:99px; padding:2px 8px; font-size:11px; }
.pill.active { background:#123d2e; color:#5ee6a8; } .pill.paused { background:#3d3512; color:#e6d05e; } .pill.exhausted { background:#3d1212; color:#e65e5e; }
.pill.pending { background:#1c2f3d; color:#5ec6e6; } .pill.rejected { background:#3d1212; color:#e65e5e; }
button.approve { border-color:#5ee6a8; color:#5ee6a8; } button.reject { border-color:#e65e5e; color:#e65e5e; }
</style></head><body>
<h1>AGENT AD NETWORK <small>— ads in AI-agent wait states</small></h1>
<div id="stats"></div>
<div id="viz">
<div id="galaxyWrap">
<div id="galaxyTitle">AD GALAXY <span id="galaxyLive">● LIVE</span> — hubs are advertisers, orbiting nodes are ads. drag to spin, wheel to zoom</div>
<canvas id="galaxy"></canvas>
<div id="galaxyTip"></div>
<div id="galaxyLegend">
<i style="background:#5ee6a8"></i>active <i style="background:#5ec6e6"></i>pending
<i style="background:#e6d05e"></i>paused <i style="background:#b06ee6"></i>exhausted <i style="background:#e65e5e"></i>rejected
</div>
</div>
<div id="charts">
<div class="chartCard"><h3>TRAFFIC · LAST 60 MIN <span><i style="color:var(--imp)">■</i> impressions <i style="color:var(--clk)">■</i> clicks</span></h3><canvas id="traffic"></canvas></div>
<div class="chartCard"><h3>UNIQUE USERS · LAST 60 MIN <span id="usersNow" style="color:var(--usr)"></span></h3><canvas id="users"></canvas></div>
<div class="chartCard" id="donutCard"><canvas id="donut"></canvas><div id="donutLegend">loading…</div></div>
</div>
</div>
<div id="controls">
<input type="search" id="q" placeholder="search all fields (space = AND)">
<span id="chips"></span>
<button id="reset">Reset</button>
</div>
<table><thead id="thead"></thead><tbody id="tbody"></tbody></table>
<script src="/admin/assets/three.min.js"></script>
<script>
const ALLCOLS = ['id','advertiser','headline','cpm_usd','budget_usd','spent_usd','impressions','clicks','ctr','status','created_at'];
const DEFAULT_ON = ['advertiser','headline','spent_usd','impressions','clicks','status','created_at']; // start tight
const LS = 'aan_admin_v1';
let state = JSON.parse(localStorage.getItem(LS) || 'null') || { cols: DEFAULT_ON, sort: 'created_at', dir: -1, q: '' };
let rows = [], open = new Set();
const save = () => localStorage.setItem(LS, JSON.stringify(state));
const fmtDate = s => new Date(s).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
// advertiser-supplied fields are untrusted — escape EVERYTHING interpolated into HTML
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const escAttr = esc;
async function load() {
const [ov, st, ts] = await Promise.all([
fetch('/admin/api/overview').then(r=>r.json()),
fetch('/api/stats').then(r=>r.json()),
fetch('/admin/api/timeseries').then(r=>r.json()).catch(()=>null)
]);
rows = ov;
document.getElementById('stats').innerHTML = [
['Advertisers', st.advertisers, '/admin/api/overview'],
['Ads', st.ads, '/admin/api/overview'],
['Impressions', st.impressions, '/admin/events?type=impression'],
['Clicks', st.clicks, '/admin/events?type=click'],
['Booked spend', '$'+st.booked_spend_usd, '/admin/api/overview']
].map(([k,v,href]) => `<div class="stat"><a href="${href}"><b>${v}</b>${k}</a></div>`).join('');
render();
try { if (ts) drawCharts(ts); galaxyUpdate(rows); } catch (e) { console.warn('viz:', e); }
}
function render() {
document.getElementById('q').value = state.q;
document.getElementById('chips').innerHTML = ALLCOLS.map(c =>
`<span class="colchip ${state.cols.includes(c)?'on':''}" onclick="toggleCol('${c}')">${c.replace('_usd','')}</span>`).join(' ');
document.getElementById('thead').innerHTML = '<tr>' + state.cols.map(c =>
`<th class="${state.sort===c?'sorted':''}" onclick="sortBy('${c}')">${c.replace('_',' ')} ${state.sort===c?(state.dir>0?'↑':'↓'):''}</th>`).join('') + '</tr>';
const terms = state.q.toLowerCase().split(/\s+/).filter(Boolean);
let view = rows.filter(r => terms.every(t => JSON.stringify(r).toLowerCase().includes(t)));
view.sort((a,b) => (a[state.sort] > b[state.sort] ? 1 : -1) * state.dir);
document.getElementById('tbody').innerHTML = view.map(r => {
const rid = escAttr(r.id);
const cell = c => {
if (c==='id') return `<a href="/admin/api/ad/${rid}" onclick="event.stopPropagation()">${esc(r.id)}</a>`;
if (c==='impressions') return `<a href="/admin/events?ad=${rid}&type=impression" onclick="event.stopPropagation()">${esc(r.impressions)}</a>`;
if (c==='clicks') return `<a href="/admin/events?ad=${rid}&type=click" onclick="event.stopPropagation()">${esc(r.clicks)}</a>`;
if (c==='status') return `<span class="pill ${escAttr(r.status)}">${esc(r.status)}</span>`;
if (c==='created_at') return `<span class="when" title="${escAttr(r.created_at)}">🕓 ${fmtDate(r.created_at)}</span>`;
if (c==='ctr') return esc(r.ctr) + '%';
if (c.endsWith('_usd')) return '$' + (+r[c]).toFixed(2);
return esc(r[c]);
};
// ad.url is server-validated http(s), but escape + never make it clickable raw
const safeUrl = /^https?:\/\//.test(r.url||'') ? `<a href="${escAttr(r.url)}" rel="noopener noreferrer nofollow" target="_blank">${esc(r.url)}</a>` : esc(r.url);
const modBtns = r.status==='pending'
? ` · <button class="approve" onclick="event.stopPropagation();moderate('${rid}','approve')">approve</button>
<button class="reject" onclick="event.stopPropagation();moderate('${rid}','reject')">reject</button>` : '';
const detail = open.has(r.id) ? `<tr class="detail"><td colspan="${state.cols.length}">
<b>${esc(r.headline)}</b> — ${esc(r.body)}<br>
target: ${safeUrl} · keywords: ${esc((r.keywords||[]).join(', '))||'—'} ·
cpm $${esc(r.cpm_usd)} · budget $${esc(r.budget_usd)} · spent $${(+r.spent_usd).toFixed(4)} ·
<a href="/admin/api/ad/${rid}">full JSON</a> · <a href="/admin/events?ad=${rid}">all events</a>${modBtns}</td></tr>` : '';
return `<tr class="row" onclick="toggleRow('${rid}')">` + state.cols.map(c=>`<td>${cell(c)}</td>`).join('') + '</tr>' + detail;
}).join('') || `<tr><td colspan="${state.cols.length}">no ads match</td></tr>`;
}
window.toggleCol = c => { state.cols = state.cols.includes(c) ? state.cols.filter(x=>x!==c) : ALLCOLS.filter(x => state.cols.includes(x) || x===c); save(); render(); };
window.sortBy = c => { state.dir = state.sort===c ? -state.dir : -1; state.sort = c; save(); render(); };
window.toggleRow = id => { open.has(id) ? open.delete(id) : open.add(id); render(); };
window.moderate = async (id, action) => { await fetch(`/admin/api/ad/${encodeURIComponent(id)}/${action}`, { method:'POST' }); load(); };
document.getElementById('q').addEventListener('input', e => { state.q = e.target.value; save(); render(); });
document.getElementById('reset').addEventListener('click', () => { state = { cols: DEFAULT_ON, sort:'created_at', dir:-1, q:'' }; save(); render(); });
/* ================= live charts (plain canvas 2D, zero deps) ================= */
const px = (cv) => { // DPR-crisp sizing
const r = cv.getBoundingClientRect(), d = window.devicePixelRatio || 1;
if (cv.width !== r.width*d || cv.height !== r.height*d) { cv.width = r.width*d; cv.height = r.height*d; }
const c = cv.getContext('2d'); c.setTransform(d,0,0,d,0,0); return [c, r.width, r.height];
};
function lineSeries(c, data, W, H, max, color, fill) {
const stepX = W / (data.length - 1 || 1);
c.beginPath();
data.forEach((v,i) => { const x = i*stepX, y = H-6 - (v/max)*(H-18); i ? c.lineTo(x,y) : c.moveTo(x,y); });
c.strokeStyle = color; c.lineWidth = 1.8; c.shadowColor = color; c.shadowBlur = 6; c.stroke(); c.shadowBlur = 0;
if (fill) { c.lineTo(W,H); c.lineTo(0,H); c.closePath();
const g = c.createLinearGradient(0,0,0,H); g.addColorStop(0,color+'55'); g.addColorStop(1,color+'00');
c.fillStyle = g; c.fill(); }
const last = data[data.length-1], y = H-6 - (last/max)*(H-18);
c.beginPath(); c.arc(W-1,y,2.5,0,7); c.fillStyle = color; c.fill();
}
function drawCharts(ts) {
const m = ts.minutes || [];
{ const [c,W,H] = px(document.getElementById('traffic'));
c.clearRect(0,0,W,H);
c.strokeStyle = '#2a2f38'; c.lineWidth = 1;
for (let i=1;i<4;i++){ c.beginPath(); c.moveTo(0,H*i/4); c.lineTo(W,H*i/4); c.stroke(); }
const imp = m.map(b=>b.impressions), clk = m.map(b=>b.clicks);
const max = Math.max(4, ...imp, ...clk);
lineSeries(c, imp, W, H, max, '#4fd1c5', true);
lineSeries(c, clk, W, H, max, '#f472b6', false);
c.fillStyle = '#8a93a3'; c.font = '10px sans-serif';
c.fillText(max, 3, 10); c.fillText('now →', W-34, H-2);
}
{ const [c,W,H] = px(document.getElementById('users'));
c.clearRect(0,0,W,H);
const us = m.map(b=>b.users), max = Math.max(2, ...us);
const bw = Math.max(2, W/us.length - 1.5);
us.forEach((v,i) => { const h = (v/max)*(H-14), x = i*(W/us.length);
c.fillStyle = v ? '#a78bfa' : '#232733';
c.beginPath(); c.roundRect(x, H-4-Math.max(h,2), bw, Math.max(h,2), 1.5); c.fill(); });
document.getElementById('usersNow').textContent = (us[us.length-1]||0) + ' this minute';
}
{ const [c,W,H] = px(document.getElementById('donut'));
c.clearRect(0,0,W,H);
const COLORS = { active:'#5ee6a8', pending:'#5ec6e6', paused:'#e6d05e', exhausted:'#b06ee6', rejected:'#e65e5e' };
const entries = Object.entries(ts.statuses||{}).filter(([,n])=>n>0);
const total = entries.reduce((s,[,n])=>s+n,0) || 1;
let a = -Math.PI/2;
for (const [k,n] of entries) {
const a2 = a + n/total*2*Math.PI;
c.beginPath(); c.arc(W/2,H/2,H/2-4,a,a2); c.strokeStyle = COLORS[k]||'#8a93a3';
c.lineWidth = 9; c.stroke(); a = a2 + 0.02;
}
c.fillStyle = '#e6e6e6'; c.font = 'bold 15px sans-serif'; c.textAlign = 'center';
c.fillText(String(ts.totals ? ts.totals.ads : total), W/2, H/2+1);
c.fillStyle = '#8a93a3'; c.font = '8px sans-serif'; c.fillText('ADS', W/2, H/2+12);
document.getElementById('donutLegend').innerHTML = entries.map(([k,n]) =>
`<div><i style="background:${COLORS[k]||'#8a93a3'}"></i>${esc(k)} · <b>${n}</b></div>`).join('') || 'no ads yet';
}
}
/* ================= three.js ad galaxy ================= */
let G = null; // galaxy state
function galaxyInit() {
if (typeof THREE === 'undefined') throw new Error('three.js unavailable');
const wrap = document.getElementById('galaxyWrap'), cv = document.getElementById('galaxy');
const renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: true, alpha: true });
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0b0d12, 0.010);
const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 500);
camera.position.set(0, 14, 46);
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.PointLight(0x4fd1c5, 1.1, 200); key.position.set(0, 30, 30); scene.add(key);
const world = new THREE.Group(); scene.add(world);
// starfield
const starGeo = new THREE.BufferGeometry(), starPos = new Float32Array(900*3);
for (let i=0;i<900;i++){ const r = 60 + Math.random()*80, t = Math.random()*Math.PI*2, ph = Math.acos(2*Math.random()-1);
starPos[i*3] = r*Math.sin(ph)*Math.cos(t); starPos[i*3+1] = r*Math.cos(ph)*0.6; starPos[i*3+2] = r*Math.sin(ph)*Math.sin(t); }
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
world.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color:0x3a4560, size:0.5, transparent:true, opacity:0.85 })));
// soft glow sprite texture (radial gradient, generated — no asset fetch)
const gc = document.createElement('canvas'); gc.width = gc.height = 64;
const g2 = gc.getContext('2d'), grad = g2.createRadialGradient(32,32,2,32,32,30);
grad.addColorStop(0,'rgba(255,255,255,.9)'); grad.addColorStop(1,'rgba(255,255,255,0)');
g2.fillStyle = grad; g2.fillRect(0,0,64,64);
const glowTex = new THREE.CanvasTexture(gc);
const nodes = new THREE.Group(); world.add(nodes);
G = { renderer, scene, camera, world, nodes, glowTex, wrap, cv,
adMesh: new Map(), lastImp: new Map(), pulses: [], hubs: new Map(),
drag: null, targetRotY: 0, rotY: 0, rotX: 0.12, targetRotX: 0.12, zoom: 46, t: 0 };
// drag to rotate, wheel to zoom (tiny hand-rolled controls — no OrbitControls dep)
cv.addEventListener('pointerdown', e => { G.drag = { x:e.clientX, y:e.clientY }; cv.style.cursor='grabbing'; });
addEventListener('pointerup', () => { G.drag = null; cv.style.cursor='grab'; });
addEventListener('pointermove', e => {
if (G.drag) { G.targetRotY += (e.clientX-G.drag.x)*0.005; G.targetRotX = Math.max(-0.6, Math.min(1.2, G.targetRotX + (e.clientY-G.drag.y)*0.003)); G.drag = { x:e.clientX, y:e.clientY }; }
hover(e);
});
cv.addEventListener('wheel', e => { e.preventDefault(); G.zoom = Math.max(16, Math.min(90, G.zoom + e.deltaY*0.05)); }, { passive:false });
const ray = new THREE.Raycaster(), ptr = new THREE.Vector2(), tip = document.getElementById('galaxyTip');
function hover(e) {
const r = cv.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) { tip.style.display='none'; return; }
ptr.x = ((e.clientX-r.left)/r.width)*2-1; ptr.y = -((e.clientY-r.top)/r.height)*2+1;
ray.setFromCamera(ptr, camera);
const hit = ray.intersectObjects([...G.adMesh.values()].map(o=>o.mesh))[0];
if (hit) { const d = hit.object.userData;
tip.innerHTML = `<b>${esc(d.headline)}</b><br>${esc(d.sponsor||'')} · <span class="pill ${escAttr(d.status)}">${esc(d.status)}</span><br>${d.impressions} impressions · ${d.clicks} clicks`;
tip.style.display = 'block';
tip.style.left = Math.min(e.clientX-r.left+14, r.width-270)+'px'; tip.style.top = (e.clientY-r.top+10)+'px';
} else tip.style.display = 'none';
}
function resize() {
const w = wrap.clientWidth, h = wrap.clientHeight;
renderer.setSize(w, h, false); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio||1));
camera.aspect = w/h; camera.updateProjectionMatrix();
}
addEventListener('resize', resize); resize();
(function tick() {
requestAnimationFrame(tick);
G.t += 0.016;
G.rotY += ((G.targetRotY + G.t*0.02) - G.rotY) * 0.06; // idle auto-orbit + drag
G.rotX += (G.targetRotX - G.rotX) * 0.08;
world.rotation.y = G.rotY; world.rotation.x = G.rotX;
camera.position.z += (G.zoom - camera.position.z) * 0.08;
for (const o of G.adMesh.values()) {
o.angle += o.speed;
o.mesh.position.set(o.hub.x + Math.cos(o.angle)*o.r, o.hub.y + Math.sin(o.angle*0.7)*1.6 + o.tilt*Math.sin(o.angle), o.hub.z + Math.sin(o.angle)*o.r);
o.glow.position.copy(o.mesh.position);
const s = 1 + Math.max(0, o.punch); o.punch *= 0.94;
o.mesh.scale.setScalar(s); o.glow.scale.setScalar(o.baseGlow*s);
if (o.line) { o.line.geometry.setFromPoints([new THREE.Vector3(o.hub.x,o.hub.y,o.hub.z), o.mesh.position]); }
}
for (const h of G.hubs.values()) h.mesh.rotation.y += 0.01;
for (let i=G.pulses.length-1;i>=0;i--) { const pu = G.pulses[i];
pu.mesh.scale.multiplyScalar(1.09); pu.mesh.material.opacity *= 0.90; pu.mesh.lookAt(camera.position);
if (pu.mesh.material.opacity < 0.02) { world.remove(pu.mesh); G.pulses.splice(i,1); } }
renderer.render(scene, camera);
})();
}
const STATUS_COLOR = { active:0x5ee6a8, pending:0x5ec6e6, paused:0xe6d05e, exhausted:0xb06ee6, rejected:0xe65e5e };
function galaxyUpdate(ads) {
if (!G) return;
// hubs = advertisers, laid out on a ring
const advs = [...new Set(ads.map(a => a.advertiserId))];
advs.forEach((id, i) => {
if (G.hubs.has(id)) return;
const ang = (i / Math.max(advs.length,1)) * Math.PI*2, R = advs.length > 1 ? 16 : 0;
const pos = new THREE.Vector3(Math.cos(ang)*R, (Math.random()-0.5)*4, Math.sin(ang)*R);
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1.15, 0),
new THREE.MeshStandardMaterial({ color:0x4fd1c5, emissive:0x1f6e66, roughness:0.3, metalness:0.5, wireframe:true }));
mesh.position.copy(pos); G.world.add(mesh);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map:G.glowTex, color:0x4fd1c5, transparent:true, opacity:0.5, depthWrite:false }));
glow.scale.setScalar(5); glow.position.copy(pos); G.world.add(glow);
G.hubs.set(id, { mesh, glow, pos });
});
for (const a of ads) {
const hub = (G.hubs.get(a.advertiserId) || { pos:new THREE.Vector3(0,0,0) }).pos;
let o = G.adMesh.get(a.id);
const col = STATUS_COLOR[a.status] ?? 0x8a93a3;
const size = 0.5 + Math.log2((a.impressions||0)+2)*0.16;
if (!o) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 20, 20),
new THREE.MeshStandardMaterial({ color:col, emissive:col, emissiveIntensity:0.55, roughness:0.35 }));
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map:G.glowTex, color:col, transparent:true, opacity:0.6, depthWrite:false }));
const line = new THREE.Line(new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color:0x2f3a4a, transparent:true, opacity:0.55 }));
G.world.add(mesh); G.world.add(glow); G.world.add(line);
o = { mesh, glow, line, hub, angle: Math.random()*Math.PI*2, speed: 0.004 + Math.random()*0.006,
r: 5 + Math.random()*5, tilt: (Math.random()-0.5)*2, punch: 0, baseGlow: 3 };
G.adMesh.set(a.id, o);
}
o.mesh.geometry.dispose(); o.mesh.geometry = new THREE.SphereGeometry(size, 20, 20);
o.mesh.material.color.setHex(col); o.mesh.material.emissive.setHex(col);
o.glow.material.color.setHex(col); o.baseGlow = size*5;
o.mesh.userData = { headline:a.headline, sponsor:a.sponsor, status:a.status, impressions:a.impressions||0, clicks:a.clicks||0 };
// pulse on fresh impressions since last poll
const prev = G.lastImp.get(a.id);
if (prev != null && (a.impressions||0) > prev) {
o.punch = 0.9;
const ring = new THREE.Mesh(new THREE.RingGeometry(size*1.3, size*1.55, 40),
new THREE.MeshBasicMaterial({ color:col, transparent:true, opacity:0.85, side:THREE.DoubleSide, depthWrite:false }));
ring.position.copy(o.mesh.position); G.world.add(ring); G.pulses.push({ mesh: ring });
}
G.lastImp.set(a.id, a.impressions||0);
}
// remove nodes for deleted ads
for (const [id, o] of G.adMesh) if (!ads.some(a => a.id === id)) {
G.world.remove(o.mesh); G.world.remove(o.glow); G.world.remove(o.line); G.adMesh.delete(id);
}
}
try { galaxyInit(); } catch (e) {
console.warn('galaxy disabled:', e);
document.getElementById('galaxyTitle').textContent = 'AD GALAXY — WebGL unavailable on this browser';
}
load(); setInterval(load, 5000);
</script></body></html>