File size: 11,365 Bytes
d660fa9
 
 
 
 
 
 
 
 
 
 
 
 
 
f4542ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54f8f0b
f4542ce
54f8f0b
 
9ed53cc
 
54f8f0b
9ed53cc
 
 
 
 
54f8f0b
9ed53cc
 
 
 
 
 
54f8f0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4542ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cad6357
f4542ce
 
9ed53cc
 
f4542ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ed53cc
 
 
 
 
 
 
 
 
 
 
 
cad6357
9ed53cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4542ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
/**
 * 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" }}
    />
  );
}