/** * SimCanvas — the fullscreen map renderer. * * Draws the campus map (day + night blend by clock), agents as colored * dots with labels and glow, camera follow on focus, and drag-to-pan, * with smooth exponential interpolation between tick positions. * * Architecture: the visual heart of the dashboard, fed entirely by the * snapshot; no direct backend calls beyond fetching map image assets. * * Design: interpolation hides tick granularity; night overlay is a time- * based alpha blend, not a separate simulation state. */ import { useEffect, useRef } from "react"; import { lerp } from "../utils/lerp"; const LERP_FACTOR = 0.12; const CAM_LERP = 0.10; function nightAlphaForHour(h) { // 0.85 at 00–04 and 22–24, ~0 between 09 and 17, smooth ramps between. if (h >= 22 || h < 5) return 0.82; if (h >= 9 && h <= 16) return 0.0; if (h >= 5 && h < 9) return 0.82 * (1 - (h - 5) / 4); // dawn ramp down return 0.82 * ((h - 16) / 6); // dusk ramp up (17–22) } export default function SimCanvas({ snapshot, focusedId, onFocus }) { const canvasRef = useRef(null); const mapImgRef = useRef(null); const nightImgRef = useRef(null); const agentsRef = useRef({}); const rafRef = useRef(null); const nightAlphaRef = useRef(0); const camRef = useRef({ x: 0, y: 0, tx: 0, ty: 0, userPan: false }); const viewRef = useRef({ ox: 0, oy: 0, scale: 1 }); const focusRef = useRef(null); useEffect(() => { focusRef.current = focusedId; }, [focusedId]); // Read URLs from the backend so Hugging Face Space variables work at runtime. useEffect(() => { let disposed = false; const tryLoadImage = (urls, filename, onLoaded) => { const baseUrl = import.meta.env?.BASE_URL || "/"; const cleanBase = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; const candidates = [...urls, filename, `./${filename}`, `/${filename}`, `${cleanBase}${filename}`]; let idx = 0; const loadNext = () => { if (idx >= candidates.length) return; const img = new Image(); const src = candidates[idx++]; img.onload = () => { if (!disposed) onLoaded(img); }; img.onerror = () => loadNext(); img.src = src; }; loadNext(); }; async function loadMaps() { let assets = {}; try { const response = await fetch(`${import.meta.env.BASE_URL}api/assets`); if (!response.ok) throw new Error(`HTTP ${response.status}`); assets = await response.json(); } catch (error) { console.warn("Could not read remote map configuration; using local assets.", error); } if (disposed) return; tryLoadImage(assets.map_image_url ? [assets.map_image_url] : [], "map.png", (img) => { mapImgRef.current = img; }); tryLoadImage(assets.night_image_url ? [assets.night_image_url] : [], "map_night.png", (img) => { nightImgRef.current = img; }); startLoop(); } loadMaps(); return () => { disposed = true; }; }, []); // Update targets from snapshot useEffect(() => { if (!snapshot?.agents) return; if (snapshot.time) { const h = parseInt(String(snapshot.time).split(":")[0], 10) || 0; nightAlphaRef.current = nightAlphaForHour(h); } for (const [id, data] of Object.entries(snapshot.agents)) { if (!agentsRef.current[id]) { agentsRef.current[id] = { x: data.position.x, y: data.position.y }; } const a = agentsRef.current[id]; a.tx = data.position.x; a.ty = data.position.y; a.color = data.color; a.name = data.name; a.activity = data.activity || ""; a.inConversation = data.in_conversation; } if (!rafRef.current) startLoop(); }, [snapshot]); function startLoop() { if (rafRef.current) return; function tick() { const canvas = canvasRef.current; if (!canvas) { rafRef.current = null; return; } const ctx = canvas.getContext("2d"); const W = (canvas.width = window.innerWidth); const H = (canvas.height = window.innerHeight); const img = mapImgRef.current; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, W, H); const iw = img ? img.width : 1276; const ih = img ? img.height : 1233; const scale = Math.min(W / iw, H / ih) * 0.92; const baseOx = (W - iw * scale) / 2; const baseOy = (H - ih * scale) / 2; // Camera: follow focused agent unless the user is panning. const cam = camRef.current; const fid = focusRef.current; if (fid && agentsRef.current[fid] && !cam.userPan) { const a = agentsRef.current[fid]; cam.tx = W / 2 - (baseOx + a.x * scale); cam.ty = H / 2 - (baseOy + a.y * scale); } cam.x = lerp(cam.x, cam.tx, CAM_LERP); cam.y = lerp(cam.y, cam.ty, CAM_LERP); const ox = baseOx + cam.x, oy = baseOy + cam.y; viewRef.current = { ox, oy, scale }; // advance dot positions for (const id of Object.keys(agentsRef.current)) { const a = agentsRef.current[id]; if (a.tx == null) continue; a.x = lerp(a.x, a.tx, LERP_FACTOR); a.y = lerp(a.y, a.ty, LERP_FACTOR); } ctx.save(); ctx.translate(ox, oy); ctx.scale(scale, scale); if (img) { ctx.drawImage(img, 0, 0); // Day/night blend: overlay the night map at a time-based opacity. const na = nightAlphaRef.current; if (nightImgRef.current && na > 0.01) { ctx.globalAlpha = na; ctx.drawImage(nightImgRef.current, 0, 0); ctx.globalAlpha = 1; } } else { // Procedural campus background fallback if image is loading or missing ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, iw, ih); // Draw campus grid lines ctx.strokeStyle = "rgba(255, 255, 255, 0.06)"; ctx.lineWidth = 1; const step = 60; for (let x = 0; x <= iw; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, ih); ctx.stroke(); } for (let y = 0; y <= ih; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(iw, y); ctx.stroke(); } // Campus boundary border ctx.strokeStyle = "rgba(212, 160, 74, 0.4)"; ctx.lineWidth = 3; ctx.strokeRect(0, 0, iw, ih); // Campus title label ctx.fillStyle = "rgba(255, 255, 255, 0.35)"; ctx.font = "14px 'Space Mono', monospace"; ctx.textAlign = "center"; ctx.fillText("VALHALLA CAMPUS MAP", iw / 2, 40); } // draw agents for (const id of Object.keys(agentsRef.current)) { const a = agentsRef.current[id]; if (a.x == null) continue; const focused = id === fid; const r = focused ? 7 : 4; // glow / focus ring const glowR = focused ? 22 : 11; const glow = ctx.createRadialGradient(a.x, a.y, 0, a.x, a.y, glowR); glow.addColorStop(0, (a.color || "#fff") + (focused ? "90" : "50")); glow.addColorStop(1, (a.color || "#fff") + "00"); ctx.beginPath(); ctx.arc(a.x, a.y, glowR, 0, Math.PI * 2); ctx.fillStyle = glow; ctx.fill(); if (a.inConversation) { ctx.beginPath(); ctx.arc(a.x, a.y, r + 5, 0, Math.PI * 2); ctx.strokeStyle = "#d4a04a"; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fillStyle = a.color || "#fff"; ctx.fill(); ctx.strokeStyle = focused ? "#fff" : "rgba(255,255,255,0.7)"; ctx.lineWidth = focused ? 2 : 1.2; ctx.stroke(); // labels (name + short activity) const fontPx = Math.max(9, 11 / scale); ctx.font = `${fontPx}px 'Outfit', sans-serif`; ctx.textAlign = "center"; ctx.fillStyle = "#fff"; ctx.strokeStyle = "rgba(0,0,0,0.7)"; ctx.lineWidth = 3 / scale; const ly = a.y - r - 6 / scale; ctx.strokeText(a.name || id, a.x, ly); ctx.fillText(a.name || id, a.x, ly); if (focused && a.activity) { const af = Math.max(8, 9 / scale); ctx.font = `${af}px 'Space Mono', monospace`; ctx.fillStyle = "#d4a04a"; const act = a.activity.length > 40 ? a.activity.slice(0, 40) + "…" : a.activity; ctx.strokeText(act, a.x, a.y + r + 12 / scale); ctx.fillText(act, a.x, a.y + r + 12 / scale); } } ctx.restore(); // vignette const vig = ctx.createRadialGradient(W / 2, H / 2, H * 0.15, W / 2, H / 2, H * 0.78); vig.addColorStop(0, "rgba(0,0,0,0)"); vig.addColorStop(1, "rgba(0,0,0,0.5)"); ctx.fillStyle = vig; ctx.fillRect(0, 0, W, H); rafRef.current = requestAnimationFrame(tick); } rafRef.current = requestAnimationFrame(tick); } // Click-to-focus + drag-to-pan useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let dragging = false, moved = false, sx = 0, sy = 0, startCam = null; function onDown(e) { dragging = true; moved = false; sx = e.clientX; sy = e.clientY; startCam = { ...camRef.current }; } function onMove(e) { if (!dragging) return; const dx = e.clientX - sx, dy = e.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true; camRef.current.userPan = true; camRef.current.tx = startCam.tx + dx; camRef.current.ty = startCam.ty + dy; } function onUp(e) { dragging = false; if (moved) return; // treat as a click: hit-test agents const { ox, oy, scale } = viewRef.current; const mx = (e.clientX - ox) / scale; const my = (e.clientY - oy) / scale; let best = null, bestD = 18 / scale; for (const id of Object.keys(agentsRef.current)) { const a = agentsRef.current[id]; const d = Math.hypot(a.x - mx, a.y - my); // Names are rendered above the marker, so make the visible name area // clickable too. This keeps the map as the sole, uncluttered roster. const nameHalfWidth = Math.max(22, (a.name || id).length * 3.6) / scale; const inName = Math.abs(a.x - mx) <= nameHalfWidth && my >= a.y - 24 / scale && my <= a.y - 2 / scale; if (inName || d < bestD) { bestD = d; best = id; } } if (onFocus) { if (best) { camRef.current.userPan = false; onFocus(best); } else onFocus(null); } } canvas.addEventListener("mousedown", onDown); window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); const onResize = () => { if (!rafRef.current) startLoop(); }; window.addEventListener("resize", onResize); return () => { canvas.removeEventListener("mousedown", onDown); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); window.removeEventListener("resize", onResize); cancelAnimationFrame(rafRef.current); rafRef.current = null; }; }, [onFocus]); return ( ); }