/** Campaign constellation force-graph helpers for Emberwake War Room. */ import type { WarRoomCampaign } from './warRoom'; export interface ConstellationNode { id: string; campaign: string; hits: number; online: number; conversionPct: number; pins: string[]; radius: number; color: string; brightness: number; pulsing: boolean; x: number; y: number; vx: number; vy: number; } export interface ConstellationEdge { source: string; target: string; sharedPins: string[]; } export interface ConstellationGraph { nodes: ConstellationNode[]; edges: ConstellationEdge[]; } const MIN_RADIUS = 10; const MAX_RADIUS = 36; /** Node radius scaled by hits (sqrt curve for readability). */ export function nodeRadius(hits: number, maxHits: number): number { if (hits <= 0) return MIN_RADIUS; if (maxHits <= 0) return MIN_RADIUS + 4; const t = Math.sqrt(hits / maxHits); return MIN_RADIUS + t * (MAX_RADIUS - MIN_RADIUS); } /** Brightness 0.35–1.0 from online agent count. */ export function nodeBrightness(online: number, maxOnline: number): number { if (online <= 0) return 0.35; if (maxOnline <= 0) return 1; return 0.35 + 0.65 * (online / maxOnline); } /** Aether palette: cool cyan (low) → gold (mid) → rose (high conversion). */ export function conversionColor(pct: number): string { const t = Math.max(0, Math.min(1, pct / 100)); if (t < 0.5) { const u = t / 0.5; const r = Math.round(61 + u * (201 - 61)); const g = Math.round(214 + u * (162 - 214)); const b = Math.round(198 + u * (39 - 198)); return `rgb(${r},${g},${b})`; } const u = (t - 0.5) / 0.5; const r = Math.round(201 + u * (244 - 201)); const g = Math.round(162 + u * (63 - 162)); const b = Math.round(39 + u * (94 - 39)); return `rgb(${r},${g},${b})`; } /** Edges link campaigns that share at least one pin/build id. */ export function buildConstellationEdges( campaigns: Pick[], ): ConstellationEdge[] { const edges: ConstellationEdge[] = []; const seen = new Set(); for (let i = 0; i < campaigns.length; i++) { const pinsA = new Set((campaigns[i].pins ?? []).filter(Boolean)); if (!pinsA.size) continue; for (let j = i + 1; j < campaigns.length; j++) { const shared = (campaigns[j].pins ?? []).filter((p) => pinsA.has(p)); if (!shared.length) continue; const a = campaigns[i].campaign; const b = campaigns[j].campaign; const key = a < b ? `${a}|${b}` : `${b}|${a}`; if (seen.has(key)) continue; seen.add(key); edges.push({ source: a, target: b, sharedPins: [...new Set(shared)] }); } } return edges; } /** Build graph nodes + pin-sharing edges from war-room campaigns. */ export function buildConstellationGraph(campaigns: WarRoomCampaign[]): ConstellationGraph { const maxHits = Math.max(1, ...campaigns.map((c) => c.hits ?? 0)); const maxOnline = Math.max(1, ...campaigns.map((c) => c.online ?? 0)); const nodes: ConstellationNode[] = campaigns.map((c) => { const hits = c.hits ?? 0; const online = c.online ?? 0; return { id: c.campaign, campaign: c.campaign, hits, online, conversionPct: c.conversion_pct ?? 0, pins: c.pins ?? [], radius: nodeRadius(hits, maxHits), color: conversionColor(c.conversion_pct ?? 0), brightness: nodeBrightness(online, maxOnline), pulsing: online > 0, x: 0, y: 0, vx: 0, vy: 0, }; }); return { nodes, edges: buildConstellationEdges(campaigns) }; } /** Scatter nodes in a circle for force-sim cold start. */ export function initNodePositions(nodes: ConstellationNode[], width: number, height: number): void { const cx = width / 2; const cy = height / 2; const ring = Math.min(width, height) * 0.32; nodes.forEach((n, i) => { const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2; n.x = cx + Math.cos(angle) * ring; n.y = cy + Math.sin(angle) * ring; n.vx = 0; n.vy = 0; }); } const REPULSE = 4200; const SPRING = 0.045; const SPRING_LEN = 90; const CENTER = 0.012; const DAMPING = 0.82; const PAD = 24; /** One tick of lightweight force-directed layout (no D3). */ export function tickForceLayout( nodes: ConstellationNode[], edges: ConstellationEdge[], width: number, height: number, alpha = 1, ): void { const cx = width / 2; const cy = height / 2; const nodeById = new Map(nodes.map((n) => [n.id, n])); for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const a = nodes[i]; const b = nodes[j]; let dx = b.x - a.x; let dy = b.y - a.y; let dist = Math.hypot(dx, dy) || 0.01; const minDist = a.radius + b.radius + 12; const force = (REPULSE * alpha) / (dist * dist); if (dist < minDist) { const push = ((minDist - dist) / dist) * 0.5; dx *= push; dy *= push; dist = Math.hypot(dx, dy) || 0.01; } const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy; } } for (const e of edges) { const a = nodeById.get(e.source); const b = nodeById.get(e.target); if (!a || !b) continue; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.hypot(dx, dy) || 0.01; const force = (dist - SPRING_LEN) * SPRING * alpha; const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } for (const n of nodes) { n.vx += (cx - n.x) * CENTER * alpha; n.vy += (cy - n.y) * CENTER * alpha; n.vx *= DAMPING; n.vy *= DAMPING; n.x += n.vx; n.y += n.vy; const r = n.radius + PAD; n.x = Math.max(r, Math.min(width - r, n.x)); n.y = Math.max(r, Math.min(height - r, n.y)); } } /** Run layout to near-equilibrium; returns same node references (mutated). */ export function settleForceLayout( nodes: ConstellationNode[], edges: ConstellationEdge[], width: number, height: number, ticks = 120, ): ConstellationNode[] { initNodePositions(nodes, width, height); for (let t = ticks; t > 0; t--) { tickForceLayout(nodes, edges, width, height, t / ticks); } return nodes; }