// modules/panels/agent-board.js — Agent status holographic board // Reads state.agentStatus (populated by data/gitea.js) and renders one floating // sprite panel per agent. Board arcs behind the platform on the negative-Z side. // // Data category: REAL // Data source: state.agentStatus (Gitea commits + open PRs via data/gitea.js) import * as THREE from 'three'; import { state } from '../core/state.js'; import { NEXUS } from '../core/theme.js'; import { subscribe } from '../core/ticker.js'; const BOARD_RADIUS = 9.5; const BOARD_Y = 4.2; const BOARD_SPREAD = Math.PI * 0.75; // 135° arc, centred on -Z const STATUS_COLOR = { working: NEXUS.theme.agentWorking, idle: NEXUS.theme.agentIdle, dormant: NEXUS.theme.agentDormant, dead: NEXUS.theme.agentDead, unreachable: NEXUS.theme.agentDead, }; let _group, _scene; let _lastAgentStatus = null; let _sprites = []; /** * Builds a canvas texture for a single agent holo-panel. * @param {{ name: string, status: string, issue: string|null, prs_today: number, local: boolean }} agent * @returns {THREE.CanvasTexture} */ function _makeTexture(agent) { const W = 400, H = 200; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); const sc = STATUS_COLOR[agent.status] || NEXUS.theme.accentStr; const font = NEXUS.theme.fontMono; ctx.fillStyle = 'rgba(0, 8, 24, 0.88)'; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = sc; ctx.lineWidth = 2; ctx.strokeRect(1, 1, W - 2, H - 2); ctx.globalAlpha = 0.3; ctx.strokeRect(4, 4, W - 8, H - 8); ctx.globalAlpha = 1.0; // Agent name ctx.font = `bold 28px ${font}`; ctx.fillStyle = '#ffffff'; ctx.textAlign = 'left'; ctx.fillText(agent.name.toUpperCase(), 16, 44); // Status dot ctx.beginPath(); ctx.arc(W - 30, 26, 10, 0, Math.PI * 2); ctx.fillStyle = sc; ctx.fill(); // Status label ctx.font = `13px ${font}`; ctx.fillStyle = sc; ctx.textAlign = 'right'; ctx.fillText(agent.status.toUpperCase(), W - 16, 60); // Separator ctx.strokeStyle = NEXUS.theme.panelBorderFaint; ctx.lineWidth = 1; ctx.textAlign = 'left'; ctx.beginPath(); ctx.moveTo(16, 70); ctx.lineTo(W - 16, 70); ctx.stroke(); // Current issue ctx.font = `10px ${font}`; ctx.fillStyle = NEXUS.theme.panelDim; ctx.fillText('CURRENT ISSUE', 16, 90); ctx.font = `13px ${font}`; ctx.fillStyle = NEXUS.theme.panelText; const raw = agent.issue || '\u2014 none \u2014'; ctx.fillText(raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw, 16, 110); // Separator ctx.strokeStyle = NEXUS.theme.panelBorderFaint; ctx.beginPath(); ctx.moveTo(16, 128); ctx.lineTo(W - 16, 128); ctx.stroke(); // PRs label + count ctx.font = `10px ${font}`; ctx.fillStyle = NEXUS.theme.panelDim; ctx.fillText('PRs MERGED TODAY', 16, 148); ctx.font = `bold 28px ${font}`; ctx.fillStyle = NEXUS.theme.accentStr; ctx.fillText(String(agent.prs_today), 16, 182); // Runtime indicator const isLocal = agent.local === true; const rtColor = isLocal ? NEXUS.theme.agentWorking : NEXUS.theme.agentDead; const rtLabel = isLocal ? 'LOCAL' : 'CLOUD'; ctx.font = `10px ${font}`; ctx.fillStyle = NEXUS.theme.panelDim; ctx.textAlign = 'right'; ctx.fillText('RUNTIME', W - 16, 148); ctx.font = `bold 13px ${font}`; ctx.fillStyle = rtColor; ctx.fillText(rtLabel, W - 28, 172); ctx.textAlign = 'left'; ctx.beginPath(); ctx.arc(W - 16, 167, 6, 0, Math.PI * 2); ctx.fillStyle = rtColor; ctx.fill(); return new THREE.CanvasTexture(canvas); } function _rebuild(statusData) { // Remove old sprites while (_group.children.length) _group.remove(_group.children[0]); for (const s of _sprites) { if (s.material.map) s.material.map.dispose(); s.material.dispose(); } _sprites = []; const agents = statusData.agents; const n = agents.length; agents.forEach((agent, i) => { const t = n === 1 ? 0.5 : i / (n - 1); const angle = Math.PI + (t - 0.5) * BOARD_SPREAD; const x = Math.cos(angle) * BOARD_RADIUS; const z = Math.sin(angle) * BOARD_RADIUS; const texture = _makeTexture(agent); const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false }); const sprite = new THREE.Sprite(material); sprite.scale.set(6.4, 3.2, 1); sprite.position.set(x, BOARD_Y, z); sprite.userData = { baseY: BOARD_Y, floatPhase: (i / n) * Math.PI * 2, floatSpeed: 0.18 + i * 0.04, zoomLabel: `Agent: ${agent.name}`, }; _group.add(sprite); _sprites.push(sprite); }); } /** @param {THREE.Scene} scene */ export function init(scene) { _scene = scene; _group = new THREE.Group(); scene.add(_group); // If state already has agent data (unlikely on first load, but handle it) if (state.agentStatus) { _rebuild(state.agentStatus); _lastAgentStatus = state.agentStatus; } subscribe(update); } /** * @param {number} elapsed * @param {number} delta */ export function update(elapsed, delta) { // Rebuild board when state.agentStatus changes if (state.agentStatus && state.agentStatus !== _lastAgentStatus) { _rebuild(state.agentStatus); _lastAgentStatus = state.agentStatus; } // Animate gentle float for (const sprite of _sprites) { const ud = sprite.userData; sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.15; } } export function dispose() { if (_group) _scene.remove(_group); }