valhalla / frontend /src /components /SimCanvas.jsx
Dontcryx_07
final
d660fa9
Raw
History Blame Contribute Delete
11.4 kB
/**
* 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 (
<canvas
ref={canvasRef}
style={{ position: "fixed", top: 0, left: 0, cursor: "grab", display: "block" }}
/>
);
}