sentinel-env / ui /app /components /SimCanvas.tsx
Harshit200431's picture
Added UI
1d68c54
"use client";
import { useRef, useEffect } from "react";
type AgentData = { id: string; trust: number; type: string };
type Props = {
trustSnapshot: Record<string, number>;
adversarialAgents: Set<string>;
activeSpec: string | null;
};
const AGENT_ANGLES = [0, 72, 144, 216, 288];
const COLORS: Record<string, string> = {
orch: "#00F5FF", normal: "#00FF88", degraded: "#FFB800", adversarial: "#FF2D55",
};
function hexToRgb(hex: string) {
if (hex === "#00F5FF") return "0,245,255";
if (hex === "#00FF88") return "0,255,136";
if (hex === "#FFB800") return "255,184,0";
if (hex === "#FF2D55") return "255,45,85";
return "0,200,255";
}
function agentType(id: string, trust: number, isAdv: boolean): string {
if (isAdv) return "adversarial";
if (trust < 0.4) return "degraded";
return "normal";
}
export default function SimCanvas({ trustSnapshot, adversarialAgents, activeSpec }: Props) {
const ref = useRef<HTMLCanvasElement>(null);
const dataRef = useRef({ trustSnapshot, adversarialAgents, activeSpec });
useEffect(() => {
dataRef.current = { trustSnapshot, adversarialAgents, activeSpec };
}, [trustSnapshot, adversarialAgents, activeSpec]);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let W = 0, H = 0, tick = 0, animId = 0;
type Packet = { agentIdx: number; progress: number; dir: number };
let dataPackets: Packet[] = [];
function resize() {
W = canvas!.width = canvas!.offsetWidth;
H = canvas!.height = canvas!.offsetHeight;
}
function getPos(angle: number, cx: number, cy: number, r: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return { x: cx + Math.cos(rad) * r, y: cy + Math.sin(rad) * r };
}
function draw() {
const { trustSnapshot: ts, adversarialAgents: advSet, activeSpec: active } = dataRef.current;
ctx!.clearRect(0, 0, W, H);
tick++;
const cx = W / 2, cy = H / 2;
const outerR = Math.min(W, H) * 0.35;
const orchR = 30, agentR = 18;
const agents: AgentData[] = ["S0", "S1", "S2", "S3", "S4"].map((id, i) => ({
id, trust: ts[id] ?? 0.5,
type: i === 0 ? "orch" : agentType(id, ts[id] ?? 0.5, advSet.has(id)),
}));
// Grid
ctx!.strokeStyle = "rgba(0,100,200,0.04)";
ctx!.lineWidth = 0.5;
for (let x = 0; x < W; x += 40) { ctx!.beginPath(); ctx!.moveTo(x, 0); ctx!.lineTo(x, H); ctx!.stroke(); }
for (let y = 0; y < H; y += 40) { ctx!.beginPath(); ctx!.moveTo(0, y); ctx!.lineTo(W, y); ctx!.stroke(); }
// Orbit ring
ctx!.beginPath(); ctx!.arc(cx, cy, outerR, 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(0,200,255,0.06)"; ctx!.lineWidth = 1;
ctx!.setLineDash([4, 8]); ctx!.stroke(); ctx!.setLineDash([]);
// Scanning ring
const scanAngle = (tick * 0.02) % (Math.PI * 2);
ctx!.save(); ctx!.translate(cx, cy); ctx!.rotate(scanAngle);
const scanArc = ctx!.createLinearGradient(-outerR, 0, outerR, 0);
scanArc.addColorStop(0, "rgba(0,200,255,0)");
scanArc.addColorStop(1, "rgba(0,200,255,0.08)");
ctx!.beginPath(); ctx!.moveTo(0, 0); ctx!.arc(0, 0, outerR, -0.4, 0); ctx!.closePath();
ctx!.fillStyle = scanArc; ctx!.fill(); ctx!.restore();
// Connections from orch to agents
const orchPos = { x: cx, y: cy };
agents.slice(1).forEach((a, i) => {
const pos = getPos(AGENT_ANGLES[i + 1], cx, cy, outerR);
const col = COLORS[a.type] || COLORS.normal;
const alpha = a.type === "adversarial" ? 0.12 : a.trust * 0.35;
const isDash = a.type === "adversarial";
if (isDash) ctx!.setLineDash([4, 6]); else ctx!.setLineDash([]);
ctx!.beginPath(); ctx!.moveTo(orchPos.x, orchPos.y); ctx!.lineTo(pos.x, pos.y);
ctx!.strokeStyle = `rgba(${hexToRgb(col)},${alpha})`;
ctx!.lineWidth = a.trust * 2; ctx!.stroke(); ctx!.setLineDash([]);
});
// Data packets
if (tick % 20 === 0) {
const ai = Math.floor(Math.random() * 4) + 1;
dataPackets.push({ agentIdx: ai, progress: 0, dir: Math.random() > 0.5 ? 1 : -1 });
}
dataPackets = dataPackets.filter(p => p.progress <= 1);
dataPackets.forEach(p => {
p.progress += 0.025;
const a = agents[p.agentIdx];
const pos = getPos(AGENT_ANGLES[p.agentIdx], cx, cy, outerR);
const t = p.progress;
const px = orchPos.x + (pos.x - orchPos.x) * (p.dir > 0 ? t : 1 - t);
const py = orchPos.y + (pos.y - orchPos.y) * (p.dir > 0 ? t : 1 - t);
const col = COLORS[a.type] || COLORS.normal;
ctx!.beginPath(); ctx!.arc(px, py, 3, 0, Math.PI * 2); ctx!.fillStyle = col; ctx!.fill();
const g = ctx!.createRadialGradient(px, py, 0, px, py, 8);
g.addColorStop(0, col); g.addColorStop(1, "transparent");
ctx!.beginPath(); ctx!.arc(px, py, 8, 0, Math.PI * 2); ctx!.fillStyle = g; ctx!.fill();
});
// Outer agents
agents.slice(1).forEach((a, i) => {
const pos = getPos(AGENT_ANGLES[i + 1], cx, cy, outerR);
const col = COLORS[a.type] || COLORS.normal;
const pulse = 0.7 + 0.3 * Math.sin(tick * 0.05 + AGENT_ANGLES[i + 1]);
const isActive = active === a.id;
// Glow
const g = ctx!.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, agentR * 2.5);
g.addColorStop(0, `rgba(${hexToRgb(col)},${(isActive ? 0.35 : 0.2) * pulse})`);
g.addColorStop(1, "transparent");
ctx!.beginPath(); ctx!.arc(pos.x, pos.y, agentR * 2.5, 0, Math.PI * 2);
ctx!.fillStyle = g; ctx!.fill();
// Node circle
ctx!.beginPath(); ctx!.arc(pos.x, pos.y, agentR, 0, Math.PI * 2);
ctx!.fillStyle = `rgba(${hexToRgb(col)},0.1)`; ctx!.fill();
ctx!.strokeStyle = `rgba(${hexToRgb(col)},${0.6 * pulse})`;
ctx!.lineWidth = isActive ? 2.5 : 1.5; ctx!.stroke();
// Adversarial warning ring
if (a.type === "adversarial") {
ctx!.beginPath();
ctx!.arc(pos.x, pos.y, agentR + 6 + Math.sin(tick * 0.1) * 3, 0, Math.PI * 2);
ctx!.strokeStyle = `rgba(255,45,85,${0.3 * pulse})`;
ctx!.lineWidth = 1; ctx!.setLineDash([3, 4]); ctx!.stroke(); ctx!.setLineDash([]);
}
// Labels
ctx!.font = '9px "Share Tech Mono"'; ctx!.fillStyle = col; ctx!.textAlign = "center";
ctx!.fillText(a.id, pos.x, pos.y - agentR - 8);
ctx!.fillStyle = "rgba(232,244,255,0.3)";
ctx!.fillText(a.trust.toFixed(2), pos.x, pos.y + 4);
});
// Orchestrator
const orchPulse = 0.7 + 0.3 * Math.sin(tick * 0.04);
const orchG = ctx!.createRadialGradient(cx, cy, 0, cx, cy, orchR * 3);
orchG.addColorStop(0, `rgba(0,245,255,${0.15 * orchPulse})`);
orchG.addColorStop(1, "transparent");
ctx!.beginPath(); ctx!.arc(cx, cy, orchR * 3, 0, Math.PI * 2);
ctx!.fillStyle = orchG; ctx!.fill();
ctx!.beginPath(); ctx!.arc(cx, cy, orchR, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(0,245,255,0.1)"; ctx!.fill();
ctx!.strokeStyle = `rgba(0,245,255,${0.8 * orchPulse})`;
ctx!.lineWidth = 2; ctx!.stroke();
ctx!.beginPath(); ctx!.arc(cx, cy, orchR * 0.6, 0, Math.PI * 2);
ctx!.strokeStyle = `rgba(0,245,255,${0.4 * orchPulse})`;
ctx!.lineWidth = 1; ctx!.stroke();
ctx!.font = 'bold 9px "Share Tech Mono"'; ctx!.fillStyle = "#00F5FF";
ctx!.textAlign = "center"; ctx!.fillText("S0", cx, cy - 4);
ctx!.font = '8px "Share Tech Mono"'; ctx!.fillStyle = "rgba(0,245,255,0.5)";
ctx!.fillText("ORCH", cx, cy + 8);
animId = requestAnimationFrame(draw);
}
const ro = new ResizeObserver(() => resize());
ro.observe(canvas.parentElement!);
resize(); draw();
return () => { cancelAnimationFrame(animId); ro.disconnect(); };
}, []);
return <canvas ref={ref} />;
}