Load Space map assets from runtime URLs

#5
README.md CHANGED
@@ -178,6 +178,14 @@ Set values in `.env` (copy from `.env.local` first).
178
  | `SIM_GEMINI_MODEL` | `gemini-3.1-flash-lite` | Gemini model used by the simulation |
179
  | `SIM_CREATIVITY` | `1.0` | Creativity dial for plans/dialogue |
180
  | `SIM_WELLBEING_VARIABILITY` | `0.75` | Non-LLM variability in wellbeing updates |
 
 
 
 
 
 
 
 
181
 
182
  ## Project Structure
183
 
@@ -210,4 +218,4 @@ requirements.txt # Python dependencies
210
  1. Fork and create a feature branch.
211
  2. Keep changes focused and documented.
212
  3. Validate local run paths before opening a PR.
213
- 4. Include a clear summary of behavior changes and test notes.
 
178
  | `SIM_GEMINI_MODEL` | `gemini-3.1-flash-lite` | Gemini model used by the simulation |
179
  | `SIM_CREATIVITY` | `1.0` | Creativity dial for plans/dialogue |
180
  | `SIM_WELLBEING_VARIABILITY` | `0.75` | Non-LLM variability in wellbeing updates |
181
+ | `MAP_IMAGE_URL` | `""` | Public HTTPS URL for the daytime map displayed in the browser |
182
+ | `MAP_NIGHT_IMAGE_URL` | `""` | Public HTTPS URL for the night-map overlay displayed in the browser |
183
+ | `PATH_IMAGE_URL` | `""` | Public HTTPS URL for the walkable-path PNG used by the backend |
184
+
185
+ For a Hugging Face Space that keeps images in GitHub, add these as **Variables**
186
+ (not Secrets) in **Settings → Variables and secrets**. Use GitHub raw-content
187
+ URLs, for example `https://raw.githubusercontent.com/OWNER/REPO/main/assets/map.png`.
188
+ Set all three URLs; `PATH_IMAGE_URL` is required for backend route calculation.
189
 
190
  ## Project Structure
191
 
 
218
  1. Fork and create a feature branch.
219
  2. Keep changes focused and documented.
220
  3. Validate local run paths before opening a PR.
221
+ 4. Include a clear summary of behavior changes and test notes.
backend/Odin.py CHANGED
@@ -962,6 +962,20 @@ def get_entrypoints():
962
  return json.load(f)
963
 
964
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
965
  # Serve React production build (dist/) if it exists, else fallback to frontend/
966
  import mimetypes
967
  # On Windows, the registry often maps .js to text/plain, which makes browsers
 
962
  return json.load(f)
963
 
964
 
965
+ @app.get("/api/assets")
966
+ def get_asset_urls():
967
+ """Return public map URLs configured on the running Space.
968
+
969
+ Hugging Face Space variables are runtime environment variables, while the
970
+ Vite bundle is built earlier. The browser reads this endpoint so changing
971
+ a Space variable does not require committing image files to the Space.
972
+ """
973
+ return {
974
+ "map_image_url": os.environ.get("MAP_IMAGE_URL", "").strip(),
975
+ "night_image_url": os.environ.get("MAP_NIGHT_IMAGE_URL", "").strip(),
976
+ }
977
+
978
+
979
  # Serve React production build (dist/) if it exists, else fallback to frontend/
980
  import mimetypes
981
  # On Windows, the registry often maps .js to text/plain, which makes browsers
backend/pathfinder.py CHANGED
@@ -6,7 +6,10 @@ BFS shortest_path(), stats(), and is_walkable() helpers.
6
 
7
  import os
8
  import threading
 
9
  from collections import deque
 
 
10
  from PIL import Image
11
 
12
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -31,10 +34,24 @@ def _load():
31
  os.path.join(ROOT, "frontend", "path.png"),
32
  os.path.join(ROOT, "frontend", "dist", "path.png"),
33
  ]
 
 
34
  try:
35
- if path_file is None:
36
- raise FileNotFoundError("path.png not found in frontend/public, frontend, or frontend/dist")
37
- with Image.open(path_file) as image:
 
 
 
 
 
 
 
 
 
 
 
 
38
  rgb = image.convert("RGB")
39
  _W, _H = rgb.size
40
  pix = rgb.load()
 
6
 
7
  import os
8
  import threading
9
+ from io import BytesIO
10
  from collections import deque
11
+ from urllib.parse import urlparse
12
+ from urllib.request import Request, urlopen
13
  from PIL import Image
14
 
15
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
34
  os.path.join(ROOT, "frontend", "path.png"),
35
  os.path.join(ROOT, "frontend", "dist", "path.png"),
36
  ]
37
+ path_file = next((p for p in candidates if os.path.exists(p)), None)
38
+ path_url = os.environ.get("PATH_IMAGE_URL", "").strip()
39
  try:
40
+ if path_file:
41
+ image_source = path_file
42
+ else:
43
+ if not path_url:
44
+ raise FileNotFoundError("path.png not found and PATH_IMAGE_URL is not configured")
45
+ if urlparse(path_url).scheme != "https":
46
+ raise ValueError("PATH_IMAGE_URL must be an https URL")
47
+ request = Request(path_url, headers={"User-Agent": "Valhalla/1.0"})
48
+ with urlopen(request, timeout=20) as response:
49
+ image_bytes = response.read(20 * 1024 * 1024 + 1)
50
+ if len(image_bytes) > 20 * 1024 * 1024:
51
+ raise ValueError("PATH_IMAGE_URL exceeds the 20 MB download limit")
52
+ image_source = BytesIO(image_bytes)
53
+
54
+ with Image.open(image_source) as image:
55
  rgb = image.convert("RGB")
56
  _W, _H = rgb.size
57
  pix = rgb.load()
frontend/src/components/SimCanvas.jsx CHANGED
@@ -26,32 +26,46 @@ export default function SimCanvas({ snapshot, focusedId, onFocus }) {
26
 
27
  useEffect(() => { focusRef.current = focusedId; }, [focusedId]);
28
 
29
- // Load map images with candidate path fallbacks
30
  useEffect(() => {
31
- const tryLoadImage = (filename, onLoaded) => {
 
32
  const baseUrl = import.meta.env?.BASE_URL || "/";
33
  const cleanBase = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
34
- const candidates = [
35
- filename,
36
- `./${filename}`,
37
- `/${filename}`,
38
- `${cleanBase}${filename}`
39
- ];
40
  let idx = 0;
41
  const loadNext = () => {
42
  if (idx >= candidates.length) return;
43
  const img = new Image();
44
  const src = candidates[idx++];
45
- img.onload = () => onLoaded(img);
46
  img.onerror = () => loadNext();
47
  img.src = src;
48
  };
49
  loadNext();
50
  };
51
 
52
- tryLoadImage("map.png", (img) => { mapImgRef.current = img; });
53
- tryLoadImage("map_night.png", (img) => { nightImgRef.current = img; });
54
- startLoop();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }, []);
56
 
57
  // Update targets from snapshot
 
26
 
27
  useEffect(() => { focusRef.current = focusedId; }, [focusedId]);
28
 
29
+ // Read URLs from the backend so Hugging Face Space variables work at runtime.
30
  useEffect(() => {
31
+ let disposed = false;
32
+ const tryLoadImage = (urls, filename, onLoaded) => {
33
  const baseUrl = import.meta.env?.BASE_URL || "/";
34
  const cleanBase = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
35
+ const candidates = [...urls, filename, `./${filename}`, `/${filename}`, `${cleanBase}${filename}`];
 
 
 
 
 
36
  let idx = 0;
37
  const loadNext = () => {
38
  if (idx >= candidates.length) return;
39
  const img = new Image();
40
  const src = candidates[idx++];
41
+ img.onload = () => { if (!disposed) onLoaded(img); };
42
  img.onerror = () => loadNext();
43
  img.src = src;
44
  };
45
  loadNext();
46
  };
47
 
48
+ async function loadMaps() {
49
+ let assets = {};
50
+ try {
51
+ const response = await fetch(`${import.meta.env.BASE_URL}api/assets`);
52
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
53
+ assets = await response.json();
54
+ } catch (error) {
55
+ console.warn("Could not read remote map configuration; using local assets.", error);
56
+ }
57
+ if (disposed) return;
58
+ tryLoadImage(assets.map_image_url ? [assets.map_image_url] : [], "map.png", (img) => {
59
+ mapImgRef.current = img;
60
+ });
61
+ tryLoadImage(assets.night_image_url ? [assets.night_image_url] : [], "map_night.png", (img) => {
62
+ nightImgRef.current = img;
63
+ });
64
+ startLoop();
65
+ }
66
+
67
+ loadMaps();
68
+ return () => { disposed = true; };
69
  }, []);
70
 
71
  // Update targets from snapshot