Spaces:
Running
on
Zero
Running
on
Zero
Upload 12 files
Browse files- README.md +10 -5
- app.py +941 -0
- data_manager.py +92 -0
- reference.py +102 -0
- requirements.txt +12 -0
- static/admin.js +143 -0
- static/script.js +217 -0
- static/style.css +169 -0
- templates/admin.html +80 -0
- templates/game.html +65 -0
- templates/index.html +60 -0
- zones.json +66 -0
README.md
CHANGED
@@ -1,12 +1,17 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: gradio
|
7 |
-
sdk_version: 5.
|
8 |
app_file: app.py
|
9 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
10 |
---
|
11 |
|
12 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
---
|
2 |
+
title: Geogussr LLM
|
3 |
+
emoji: 📚
|
4 |
+
colorFrom: pink
|
5 |
+
colorTo: pink
|
6 |
sdk: gradio
|
7 |
+
sdk_version: 5.46.0
|
8 |
app_file: app.py
|
9 |
pinned: false
|
10 |
+
hf_oauth: true
|
11 |
+
hf_oauth_expiration_minutes: 1440
|
12 |
+
hf_oauth_scopes:
|
13 |
+
- read-repos
|
14 |
+
- write-repos
|
15 |
---
|
16 |
|
17 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
ADDED
@@ -0,0 +1,941 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import random
|
3 |
+
import json
|
4 |
+
import uuid
|
5 |
+
import time
|
6 |
+
from datetime import datetime, timedelta, timezone
|
7 |
+
from typing import Dict, Any, List, Optional
|
8 |
+
import spaces
|
9 |
+
|
10 |
+
import requests
|
11 |
+
from dotenv import load_dotenv
|
12 |
+
import gradio as gr
|
13 |
+
from gradio.components import LoginButton
|
14 |
+
|
15 |
+
import data_manager
|
16 |
+
|
17 |
+
from huggingface_hub import HfApi, hf_hub_download, whoami
|
18 |
+
|
19 |
+
from transformers import Mistral3ForConditionalGeneration, AutoTokenizer, TextIteratorStreamer
|
20 |
+
import threading
|
21 |
+
import torch
|
22 |
+
|
23 |
+
load_dotenv()
|
24 |
+
|
25 |
+
APP_SECRET = os.urandom(24)
|
26 |
+
|
27 |
+
ZONES_FILE = 'zones.json'
|
28 |
+
zones = {
|
29 |
+
"easy": [],
|
30 |
+
"medium": [],
|
31 |
+
"hard": []
|
32 |
+
}
|
33 |
+
|
34 |
+
user_sessions: Dict[str, Dict[str, Any]] = {}
|
35 |
+
DEFAULT_USERNAME = "player"
|
36 |
+
|
37 |
+
def save_zones_to_file():
|
38 |
+
with open(ZONES_FILE, 'w') as f:
|
39 |
+
json.dump(zones, f, indent=4)
|
40 |
+
|
41 |
+
def load_zones_from_file():
|
42 |
+
global zones
|
43 |
+
if os.path.exists(ZONES_FILE):
|
44 |
+
try:
|
45 |
+
with open(ZONES_FILE, 'r') as f:
|
46 |
+
loaded_zones = json.load(f)
|
47 |
+
if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])):
|
48 |
+
raise ValueError("Invalid format")
|
49 |
+
migrated = False
|
50 |
+
for difficulty in loaded_zones:
|
51 |
+
for zone in loaded_zones[difficulty]:
|
52 |
+
if 'id' not in zone:
|
53 |
+
zone['id'] = uuid.uuid4().hex
|
54 |
+
migrated = True
|
55 |
+
zones = loaded_zones
|
56 |
+
print(zones)
|
57 |
+
if migrated:
|
58 |
+
print("Info: Migrated old zone data by adding unique IDs.")
|
59 |
+
save_zones_to_file()
|
60 |
+
except (json.JSONDecodeError, IOError, ValueError):
|
61 |
+
print(f"Warning: '{ZONES_FILE}' is corrupted or invalid. Recreating with empty zones.")
|
62 |
+
save_zones_to_file()
|
63 |
+
else:
|
64 |
+
save_zones_to_file()
|
65 |
+
|
66 |
+
LOCATIONS = [
|
67 |
+
{'lat': 48.85824, 'lng': 2.2945},
|
68 |
+
{'lat': 40.748440, 'lng': -73.985664},
|
69 |
+
{'lat': 35.689487, 'lng': 139.691711},
|
70 |
+
{'lat': -33.856784, 'lng': 151.215297}
|
71 |
+
]
|
72 |
+
|
73 |
+
def generate_id():
|
74 |
+
return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10))
|
75 |
+
|
76 |
+
HF_DATASET_REPO = 'jofthomas/geoguessr_game_of_the_day'
|
77 |
+
|
78 |
+
GOOGLE_MAPS_API_KEY = os.getenv('GOOGLE_MAPS_API_KEY')
|
79 |
+
SERVER_HF_TOKEN = os.getenv('HF_TOKEN', '')
|
80 |
+
|
81 |
+
model_id = "mistralai/Magistral-Small-2509"
|
82 |
+
tokenizer = AutoTokenizer.from_pretrained(model_id, tokenizer_type="mistral", use_fast=False)
|
83 |
+
model = Mistral3ForConditionalGeneration.from_pretrained(
|
84 |
+
model_id, torch_dtype=torch.bfloat16, device_map="auto"
|
85 |
+
).eval()
|
86 |
+
|
87 |
+
|
88 |
+
SYSTEM_PROMPT_TEXT = (
|
89 |
+
"You are a world-class geolocation expert. Given a street-view style image, "
|
90 |
+
"think step by step about visual clues and infer approximate coordinates. "
|
91 |
+
"When you conclude, output your answer inside [ANSWER]lat,lng[/ANSWER]."
|
92 |
+
)
|
93 |
+
@spaces.GPU(duration=120)
|
94 |
+
def llm_decode_image_return_text(image_bytes: bytes) -> str:
|
95 |
+
print(f"[llm] decode start. image_bytes={len(image_bytes)} bytes")
|
96 |
+
import base64, mimetypes
|
97 |
+
try:
|
98 |
+
encoded_image = base64.b64encode(image_bytes).decode('utf-8')
|
99 |
+
mime_type = 'image/jpeg'
|
100 |
+
data_url = f"data:{mime_type};base64,{encoded_image}"
|
101 |
+
messages = [
|
102 |
+
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_TEXT}]},
|
103 |
+
{"role": "user", "content": [
|
104 |
+
{"type": "text", "text": "Please analyze this image and provide coordinates."},
|
105 |
+
{"type": "image_url", "image_url": {"url": data_url}},
|
106 |
+
]},
|
107 |
+
]
|
108 |
+
print(f"[llm] messages prepared. system+user with image_url length={len(data_url)}")
|
109 |
+
tokenized = tokenizer.apply_chat_template(messages, return_dict=True)
|
110 |
+
print(f"[llm] tokenized keys={list(tokenized.keys())}")
|
111 |
+
import torch
|
112 |
+
input_ids = torch.tensor(tokenized.input_ids).unsqueeze(0)
|
113 |
+
attention_mask = torch.tensor(tokenized.attention_mask).unsqueeze(0)
|
114 |
+
print(f"[llm] input_ids shape={tuple(input_ids.shape)} attn_mask shape={tuple(attention_mask.shape)} device={model.device}")
|
115 |
+
kwargs = {
|
116 |
+
'input_ids': input_ids.to(model.device),
|
117 |
+
'attention_mask': attention_mask.to(model.device),
|
118 |
+
}
|
119 |
+
if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0:
|
120 |
+
pixel_values = torch.tensor(tokenized.pixel_values[0], dtype=model.dtype).unsqueeze(0).to(model.device)
|
121 |
+
image_sizes = torch.tensor(pixel_values.shape[-2:]).unsqueeze(0).to(model.device)
|
122 |
+
kwargs.update({'pixel_values': pixel_values, 'image_sizes': image_sizes})
|
123 |
+
print(f"[llm] pixel_values shape={tuple(pixel_values.shape)} image_sizes={tuple(image_sizes.shape)}")
|
124 |
+
output = model.generate(**kwargs)[0]
|
125 |
+
print(f"[llm] generate done. output length={len(output)}")
|
126 |
+
decoded = tokenizer.decode(output[len(tokenized.input_ids): ( -1 if output[-1] == tokenizer.eos_token_id else len(output) )])
|
127 |
+
print(f"[llm] decode done. text length={len(decoded)}")
|
128 |
+
return decoded
|
129 |
+
except Exception as e:
|
130 |
+
print(f"[llm] decode failed: {e}")
|
131 |
+
return f"[Error] {e}"
|
132 |
+
|
133 |
+
@spaces.GPU(duration=120)
|
134 |
+
def llm_stream_image_text(image_bytes: bytes):
|
135 |
+
print(f"[llm-stream] start. image_bytes={len(image_bytes)} bytes")
|
136 |
+
import base64
|
137 |
+
try:
|
138 |
+
encoded_image = base64.b64encode(image_bytes).decode('utf-8')
|
139 |
+
data_url = f"data:image/jpeg;base64,{encoded_image}"
|
140 |
+
messages = [
|
141 |
+
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT_TEXT}]},
|
142 |
+
{"role": "user", "content": [
|
143 |
+
{"type": "text", "text": "Please analyze this image and provide coordinates."},
|
144 |
+
{"type": "image_url", "image_url": {"url": data_url}},
|
145 |
+
]},
|
146 |
+
]
|
147 |
+
tokenized = tokenizer.apply_chat_template(messages, return_dict=True)
|
148 |
+
input_ids = torch.tensor(tokenized.input_ids).unsqueeze(0)
|
149 |
+
attention_mask = torch.tensor(tokenized.attention_mask).unsqueeze(0)
|
150 |
+
kwargs = {
|
151 |
+
'input_ids': input_ids.to(model.device),
|
152 |
+
'attention_mask': attention_mask.to(model.device),
|
153 |
+
'max_new_tokens': 512,
|
154 |
+
}
|
155 |
+
if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0:
|
156 |
+
pixel_values = torch.tensor(tokenized.pixel_values[0], dtype=model.dtype).unsqueeze(0).to(model.device)
|
157 |
+
image_sizes = torch.tensor(pixel_values.shape[-2:]).unsqueeze(0).to(model.device)
|
158 |
+
kwargs.update({'pixel_values': pixel_values, 'image_sizes': image_sizes})
|
159 |
+
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=False)
|
160 |
+
kwargs['streamer'] = streamer
|
161 |
+
thread = threading.Thread(target=model.generate, kwargs=kwargs)
|
162 |
+
thread.start()
|
163 |
+
acc = ""
|
164 |
+
for new_text in streamer:
|
165 |
+
acc += new_text
|
166 |
+
yield acc
|
167 |
+
except Exception as e:
|
168 |
+
yield f"[Error] {e}"
|
169 |
+
|
170 |
+
|
171 |
+
def zones_get() -> dict:
|
172 |
+
return zones
|
173 |
+
|
174 |
+
|
175 |
+
def seconds_until_midnight_utc() -> int:
|
176 |
+
now = datetime.now(timezone.utc)
|
177 |
+
tomorrow = (now + timedelta(days=1)).date()
|
178 |
+
midnight = datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
|
179 |
+
return max(0, int((midnight - now).total_seconds()))
|
180 |
+
|
181 |
+
|
182 |
+
|
183 |
+
def pick_random_location(difficulty: str) -> Dict[str, float]:
|
184 |
+
candidates = zones.get(difficulty, [])
|
185 |
+
if candidates:
|
186 |
+
selected_zone = random.choice(candidates)
|
187 |
+
if selected_zone.get('type') == 'rectangle':
|
188 |
+
b = selected_zone['bounds']
|
189 |
+
north, south, east, west = b['north'], b['south'], b['east'], b['west']
|
190 |
+
if west > east:
|
191 |
+
east += 360
|
192 |
+
lng = random.uniform(west, east)
|
193 |
+
if lng > 180:
|
194 |
+
lng -= 360
|
195 |
+
lat = random.uniform(south, north)
|
196 |
+
ensured = _ensure_street_view_location(lat, lng)
|
197 |
+
if ensured:
|
198 |
+
return ensured
|
199 |
+
fallback = random.choice(LOCATIONS)
|
200 |
+
ensured_fallback = _ensure_street_view_location(fallback['lat'], fallback['lng'])
|
201 |
+
return ensured_fallback or fallback
|
202 |
+
|
203 |
+
|
204 |
+
def street_view_image_url(lat: float, lng: float) -> str:
|
205 |
+
if not GOOGLE_MAPS_API_KEY:
|
206 |
+
# Fallback placeholder to avoid blank image when key is missing
|
207 |
+
return "https://picsum.photos/960/540"
|
208 |
+
return (
|
209 |
+
f"https://maps.googleapis.com/maps/api/streetview?size=960x540&location={lat},{lng}&fov=90&pitch=0&key={GOOGLE_MAPS_API_KEY}"
|
210 |
+
)
|
211 |
+
|
212 |
+
|
213 |
+
def _has_street_view(lat: float, lng: float) -> bool:
|
214 |
+
if not GOOGLE_MAPS_API_KEY:
|
215 |
+
return True
|
216 |
+
try:
|
217 |
+
resp = requests.get(
|
218 |
+
"https://maps.googleapis.com/maps/api/streetview/metadata",
|
219 |
+
params={"location": f"{lat},{lng}", "key": GOOGLE_MAPS_API_KEY},
|
220 |
+
timeout=5,
|
221 |
+
)
|
222 |
+
resp.raise_for_status()
|
223 |
+
data = resp.json()
|
224 |
+
return data.get("status") == "OK"
|
225 |
+
except Exception:
|
226 |
+
return False
|
227 |
+
|
228 |
+
|
229 |
+
def _snap_to_nearest_road(lat: float, lng: float) -> Optional[Dict[str, float]]:
|
230 |
+
if not GOOGLE_MAPS_API_KEY:
|
231 |
+
return None
|
232 |
+
try:
|
233 |
+
resp = requests.get(
|
234 |
+
"https://roads.googleapis.com/v1/nearestRoads",
|
235 |
+
params={"points": f"{lat},{lng}", "key": GOOGLE_MAPS_API_KEY},
|
236 |
+
timeout=5,
|
237 |
+
)
|
238 |
+
resp.raise_for_status()
|
239 |
+
data = resp.json()
|
240 |
+
points = data.get("snappedPoints") or []
|
241 |
+
if not points:
|
242 |
+
return None
|
243 |
+
loc = points[0].get("location") or {}
|
244 |
+
if "latitude" in loc and "longitude" in loc:
|
245 |
+
return {"lat": float(loc["latitude"]), "lng": float(loc["longitude"])}
|
246 |
+
except Exception:
|
247 |
+
pass
|
248 |
+
return None
|
249 |
+
|
250 |
+
|
251 |
+
def _ensure_street_view_location(lat: float, lng: float) -> Optional[Dict[str, float]]:
|
252 |
+
"""Return a coordinate with confirmed Street View coverage, snapped near a road when possible."""
|
253 |
+
if not GOOGLE_MAPS_API_KEY:
|
254 |
+
return {"lat": lat, "lng": lng}
|
255 |
+
|
256 |
+
checked: set[tuple] = set()
|
257 |
+
snapped = _snap_to_nearest_road(lat, lng)
|
258 |
+
candidates: List[Dict[str, float]] = []
|
259 |
+
if snapped:
|
260 |
+
candidates.append(snapped)
|
261 |
+
candidates.append({"lat": lat, "lng": lng})
|
262 |
+
|
263 |
+
# Explore a few jittered points if needed
|
264 |
+
if not snapped:
|
265 |
+
increments = [0.0005, -0.0005, 0.001, -0.001]
|
266 |
+
for d_lat in increments:
|
267 |
+
for d_lng in increments:
|
268 |
+
if d_lat == 0 and d_lng == 0:
|
269 |
+
continue
|
270 |
+
candidates.append({"lat": lat + d_lat, "lng": lng + d_lng})
|
271 |
+
|
272 |
+
for candidate in candidates:
|
273 |
+
key = (round(candidate["lat"], 6), round(candidate["lng"], 6))
|
274 |
+
if key in checked:
|
275 |
+
continue
|
276 |
+
checked.add(key)
|
277 |
+
if _has_street_view(candidate["lat"], candidate["lng"]):
|
278 |
+
return candidate
|
279 |
+
|
280 |
+
return None
|
281 |
+
|
282 |
+
|
283 |
+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
284 |
+
from math import radians, cos, sin, asin, sqrt
|
285 |
+
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
286 |
+
dlon = lon2 - lon1
|
287 |
+
dlat = lat2 - lat1
|
288 |
+
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
289 |
+
c = 2 * asin(sqrt(a))
|
290 |
+
r = 6371
|
291 |
+
return c * r
|
292 |
+
|
293 |
+
|
294 |
+
def score_from_distance_km(distance_km: float) -> float:
|
295 |
+
max_score = 5000.0
|
296 |
+
return max(0.0, max_score - distance_km)
|
297 |
+
|
298 |
+
|
299 |
+
def build_street_html(image_url: str) -> str:
|
300 |
+
base = """
|
301 |
+
<div id="image-container" style="position:relative;max-width:960px;margin:0 auto;">
|
302 |
+
<img id="street-image" src="__IMG_URL__" style="width:100%;height:auto;border-radius:8px;box-shadow:0 4px 6px rgba(0,0,0,0.1)" />
|
303 |
+
<div id="mini-map-wrap" style="transition:all 0.3s ease;position:absolute;right:10px;bottom:10px;border:2px solid #fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.2);background:#fff;">
|
304 |
+
<div id="mini-map" style="width:100%;height:100%;cursor:pointer"></div>
|
305 |
+
<div id="map-controls" style="position:absolute;right:8px;top:8px;display:flex;gap:6px;z-index:5;">
|
306 |
+
<button id="map-size-minus" class="map-ctrl" style="width:34px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.2);background:#f97316;color:#fff;">−</button>
|
307 |
+
<button id="map-size-plus" class="map-ctrl" style="width:34px;height:28px;border-radius:6px;border:1px solid rgba(0,0,0,0.2);background:#f97316;color:#fff;">+</button>
|
308 |
+
</div>
|
309 |
+
</div>
|
310 |
+
</div>
|
311 |
+
"""
|
312 |
+
return base.replace('__IMG_URL__', image_url)
|
313 |
+
|
314 |
+
|
315 |
+
def gr_start_game(difficulty: str, username: str, request: gr.Request):
|
316 |
+
rounds: List[Dict[str, Any]] = []
|
317 |
+
date_str = datetime.now(timezone.utc).date().isoformat()
|
318 |
+
for _ in range(3):
|
319 |
+
loc = pick_random_location(difficulty)
|
320 |
+
round_id = generate_id()
|
321 |
+
rounds.append({
|
322 |
+
'id': round_id,
|
323 |
+
'lat': loc['lat'],
|
324 |
+
'lng': loc['lng'],
|
325 |
+
'image_url': street_view_image_url(loc['lat'], loc['lng']),
|
326 |
+
'human_guess': None,
|
327 |
+
'ai_guess': None,
|
328 |
+
'human_score': 0.0,
|
329 |
+
'ai_score': 0.0,
|
330 |
+
})
|
331 |
+
|
332 |
+
user_sessions[username] = {
|
333 |
+
'difficulty': difficulty,
|
334 |
+
'rounds': rounds,
|
335 |
+
'total_score': 0.0,
|
336 |
+
'completed': False,
|
337 |
+
'date': date_str,
|
338 |
+
}
|
339 |
+
r0 = rounds[0]
|
340 |
+
street_html = build_street_html(r0['image_url'])
|
341 |
+
return rounds, 0, r0['id'], street_html, "", ""
|
342 |
+
|
343 |
+
|
344 |
+
def get_round(username: str, round_id: str) -> Optional[Dict[str, Any]]:
|
345 |
+
session_data = user_sessions.get(username)
|
346 |
+
if not session_data:
|
347 |
+
return None
|
348 |
+
for r in session_data['rounds']:
|
349 |
+
if r['id'] == round_id:
|
350 |
+
return r
|
351 |
+
return None
|
352 |
+
|
353 |
+
|
354 |
+
def gr_submit_guess(round_id: str, lat: float, lng: float, username: str, request: gr.Request):
|
355 |
+
rnd = get_round(username, round_id)
|
356 |
+
if not rnd:
|
357 |
+
return "", "Round not found", gr.update(), gr.update(), gr.update()
|
358 |
+
distance_km = haversine_km(rnd['lat'], rnd['lng'], float(lat), float(lng))
|
359 |
+
score = score_from_distance_km(distance_km)
|
360 |
+
rnd['human_guess'] = {'lat': float(lat), 'lng': float(lng)}
|
361 |
+
rnd['human_score'] = score
|
362 |
+
rnd['human_distance_km'] = float(distance_km)
|
363 |
+
result_text = f"Your guess was {distance_km:.2f} km away. You scored {score:.0f} points."
|
364 |
+
scoreboard_html = (
|
365 |
+
f"<div style='margin:8px 0;padding:26px;background:#040b1a;border-radius:18px;color:#f9fafb;border:1px solid rgba(148,163,184,0.4);box-shadow:0 26px 60px rgba(4,7,15,0.6);'>"
|
366 |
+
f"<div style='font-weight:700;font-size:1.25rem;margin-bottom:14px;text-shadow:0 0 12px rgba(4,7,15,0.75);'>Human Guess Recorded</div>"
|
367 |
+
f"<div style='margin-bottom:20px;color:#f8fafc;font-size:1.02rem;line-height:1.6;text-shadow:0 0 10px rgba(4,7,15,0.65);'>{result_text}</div>"
|
368 |
+
f"<div style='display:flex;gap:24px;font-size:0.96rem;margin-bottom:18px;flex-wrap:wrap;color:#f8fafc;'>"
|
369 |
+
f" <span><strong>Distance:</strong> {distance_km:.2f} km</span>"
|
370 |
+
f" <span><strong>Score:</strong> {score:.0f} pts</span>"
|
371 |
+
f"</div>"
|
372 |
+
f"<div style='font-size:0.92rem;color:#e2e8f0;'>AI analysis will be added once the model finishes.</div>"
|
373 |
+
f"</div>"
|
374 |
+
)
|
375 |
+
popup_html = """
|
376 |
+
<div id=\"popup-overlay\" style=\"position:fixed;inset:0;background:rgba(8,11,20,0.78);display:flex;align-items:center;justify-content:center;z-index:1000;\">
|
377 |
+
<div id=\"popup-card\" style=\"background:#04070f;padding:26px;border-radius:18px;width:92%;max-width:960px;color:#f9fafb;position:relative;box-shadow:0 28px 80px rgba(4,7,15,0.75);border:1px solid rgba(148,163,184,0.35);\" onclick=\"event.stopPropagation();\">
|
378 |
+
<button id=\"popup-close-next\" style=\"position:absolute;top:16px;right:18px;padding:10px 18px;border-radius:999px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 8px 18px rgba(37,99,235,0.45);\">Close & Next</button>
|
379 |
+
<h3 style=\"margin:0 0 20px;color:#f8fafc;text-shadow:0 0 14px rgba(37,99,235,0.35);\">Round Results</h3>
|
380 |
+
__SCOREBOARD__
|
381 |
+
<div id=\"popup-map\" data-rnd-lat=\"__RND_LAT__\" data-rnd-lng=\"__RND_LNG__\" data-h-lat=\"__H_LAT__\" data-h-lng=\"__H_LNG__\" data-ai-lat=\"\" data-ai-lng=\"\" style=\"width:100%;height:440px;border-radius:16px;overflow:hidden;border:1px solid rgba(148,163,184,0.35);box-shadow:0 16px 40px rgba(4,7,15,0.72);\"></div>
|
382 |
+
<div style=\"display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:0.98rem;color:#e2e8f0;flex-wrap:wrap;gap:14px;\">
|
383 |
+
<div style=\"display:flex;gap:16px;align-items:center;flex-wrap:wrap;\">
|
384 |
+
<span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.6);\"></span>G = Ground Truth</span>
|
385 |
+
<span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#f97316;box-shadow:0 0 8px rgba(249,115,22,0.55);\"></span>H = Human</span>
|
386 |
+
<span style=\"display:flex;align-items:center;gap:8px;\"><span style=\"display:inline-flex;width:13px;height:13px;border-radius:50%;background:#2563EB;box-shadow:0 0 8px rgba(37,99,235,0.6);\"></span>A = AI</span>
|
387 |
+
</div>
|
388 |
+
<button id=\"popup-close-next-footer\" style=\"padding:12px 22px;border-radius:12px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 10px 22px rgba(37,99,235,0.48);\">Next Round</button>
|
389 |
+
</div>
|
390 |
+
</div>
|
391 |
+
</div>
|
392 |
+
<script>
|
393 |
+
(function() {
|
394 |
+
function boot() {
|
395 |
+
if (window.__initPopupIfPresent) {
|
396 |
+
window.__initPopupIfPresent();
|
397 |
+
}
|
398 |
+
}
|
399 |
+
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
400 |
+
setTimeout(boot, 0);
|
401 |
+
} else {
|
402 |
+
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
403 |
+
}
|
404 |
+
})();
|
405 |
+
</script>
|
406 |
+
""".replace('__SCOREBOARD__', scoreboard_html)\
|
407 |
+
.replace('__RND_LAT__', str(rnd['lat']))\
|
408 |
+
.replace('__RND_LNG__', str(rnd['lng']))\
|
409 |
+
.replace('__H_LAT__', str(float(lat)))\
|
410 |
+
.replace('__H_LNG__', str(float(lng)))\
|
411 |
+
.replace('__GMAPS_KEY__', GOOGLE_MAPS_API_KEY or '')
|
412 |
+
return popup_html, result_text, rnd['lat'], rnd['lng'], score
|
413 |
+
|
414 |
+
def extract_coords_from_text(text: str) -> Optional[Dict[str, float]]:
|
415 |
+
import re
|
416 |
+
m = re.search(r"\[ANSWER\]\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\[/ANSWER\]", text, re.IGNORECASE)
|
417 |
+
if not m:
|
418 |
+
return None
|
419 |
+
try:
|
420 |
+
lat = float(m.group(1))
|
421 |
+
lng = float(m.group(2))
|
422 |
+
return {'lat': lat, 'lng': lng}
|
423 |
+
except Exception:
|
424 |
+
return None
|
425 |
+
|
426 |
+
|
427 |
+
def geocode_text_to_coords(query: str) -> Optional[Dict[str, float]]:
|
428 |
+
if not GOOGLE_MAPS_API_KEY:
|
429 |
+
return None
|
430 |
+
resp = requests.get('https://maps.googleapis.com/maps/api/geocode/json', params={'address': query, 'key': GOOGLE_MAPS_API_KEY})
|
431 |
+
try:
|
432 |
+
j = resp.json()
|
433 |
+
if j.get('results'):
|
434 |
+
loc = j['results'][0]['geometry']['location']
|
435 |
+
return {'lat': loc['lat'], 'lng': loc['lng']}
|
436 |
+
except Exception:
|
437 |
+
return None
|
438 |
+
return None
|
439 |
+
|
440 |
+
|
441 |
+
def gr_ai_analyze(round_id: str, request: gr.Request):
|
442 |
+
username = DEFAULT_USERNAME
|
443 |
+
rnd = get_round(username, round_id)
|
444 |
+
if not rnd:
|
445 |
+
return "Round not found", gr.update()
|
446 |
+
try:
|
447 |
+
img_resp = requests.get(rnd['image_url'])
|
448 |
+
img_resp.raise_for_status()
|
449 |
+
full_text = llm_decode_image_return_text(img_resp.content)
|
450 |
+
except Exception as e:
|
451 |
+
return f"[Error] {e}", gr.update()
|
452 |
+
ai_coords = extract_coords_from_text(full_text) or geocode_text_to_coords(full_text[-256:])
|
453 |
+
ai_marker = None
|
454 |
+
if ai_coords:
|
455 |
+
rnd['ai_guess'] = ai_coords
|
456 |
+
distance_km = haversine_km(rnd['lat'], rnd['lng'], ai_coords['lat'], ai_coords['lng'])
|
457 |
+
rnd['ai_score'] = score_from_distance_km(distance_km)
|
458 |
+
ai_marker = ai_coords
|
459 |
+
return full_text, ai_marker
|
460 |
+
|
461 |
+
|
462 |
+
def gr_session_summary(username: str, request: gr.Request):
|
463 |
+
sess = user_sessions.get(username)
|
464 |
+
if not sess:
|
465 |
+
return "No active session"
|
466 |
+
total = sum(float(r.get('human_score', 0.0)) for r in sess['rounds'])
|
467 |
+
return f"Your total score: {total:.0f} / 15000"
|
468 |
+
|
469 |
+
load_zones_from_file()
|
470 |
+
|
471 |
+
def _read_text(path: str) -> str:
|
472 |
+
try:
|
473 |
+
with open(path, 'r') as f:
|
474 |
+
return f.read()
|
475 |
+
except Exception:
|
476 |
+
return ""
|
477 |
+
|
478 |
+
APP_CSS = _read_text('static/style.css') + "\n#lat_box, #lng_box { display:none; }\n" + """
|
479 |
+
#lobby_group, #game_group{max-width:1024px;margin:24px auto;padding:16px;background:#fff;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.08)}
|
480 |
+
#start_btn{height:48px;font-weight:700}
|
481 |
+
.gradio-container{background:var(--light-color,#FFFBEB)}
|
482 |
+
body, .gradio-container, .gradio-container *{color:#111 !important}
|
483 |
+
/* force markdown text to be dark */
|
484 |
+
.gradio-container .prose, .gradio-container .prose *{color:#111 !important}
|
485 |
+
/* override: LLM output textbox uses white font on dark background */
|
486 |
+
#ai_chat, #ai_chat *{color:#fff !important}
|
487 |
+
#ai_chat textarea{background:#111 !important;color:#fff !important}
|
488 |
+
#ai_chat label{color:#fff !important}
|
489 |
+
/* difficulty dropdown white text */
|
490 |
+
#difficulty_select, #difficulty_select *{color:#fff !important}
|
491 |
+
/* keep dropdown menu items readable */
|
492 |
+
.svelte-3lgy39 .wrap-inner, .wrap-inner{ color: inherit; }
|
493 |
+
#popup-overlay, #popup-overlay * {color:#f9fafb !important}
|
494 |
+
#popup-overlay #ai-analysis-box, #popup-overlay #ai-analysis-box * { color: #1e293b !important; }
|
495 |
+
"""
|
496 |
+
|
497 |
+
# Client boot JS to initialize the mini-map reliably in Gradio (scripts in HTML are sanitized)
|
498 |
+
APP_BOOT_JS = """
|
499 |
+
() => {
|
500 |
+
const GMAPS_KEY = "__GMAPS_KEY__";
|
501 |
+
const log = (...a) => { try { console.log('[boot]', ...a); } catch(_) {} };
|
502 |
+
function ensureMapsLoaded(cb) {
|
503 |
+
if (window.google && google.maps) return cb();
|
504 |
+
if (!GMAPS_KEY) { log('No GOOGLE_MAPS_API_KEY; mini-map disabled'); return; }
|
505 |
+
window.__gmapsQueue = window.__gmapsQueue || [];
|
506 |
+
window.__gmapsQueue.push(cb);
|
507 |
+
if (window.__gmapsLoading) return;
|
508 |
+
window.__gmapsLoading = true;
|
509 |
+
window.__mini_cb__ = () => {
|
510 |
+
log('Google Maps ready');
|
511 |
+
const q = window.__gmapsQueue || [];
|
512 |
+
q.forEach(fn => { try { fn(); } catch(_) {} });
|
513 |
+
window.__gmapsQueue = [];
|
514 |
+
};
|
515 |
+
const s = document.createElement('script');
|
516 |
+
s.async = true; s.defer = true; s.dataset.gmapsLoader = '1';
|
517 |
+
s.src = 'https://maps.googleapis.com/maps/api/js?key=' + GMAPS_KEY + '&callback=__mini_cb__';
|
518 |
+
s.onerror = () => log('Failed to load Google Maps script');
|
519 |
+
document.head.appendChild(s);
|
520 |
+
}
|
521 |
+
function initMiniMapIfPresent() {
|
522 |
+
const el = document.getElementById('mini-map');
|
523 |
+
if (!el || el.dataset.initialized === '1') return;
|
524 |
+
ensureMapsLoaded(() => {
|
525 |
+
try {
|
526 |
+
const map = new google.maps.Map(el, { center: { lat: 0, lng: 0 }, zoom: 1, streetViewControl: false, mapTypeControl: false, fullscreenControl: false });
|
527 |
+
window._miniMapInstance = map;
|
528 |
+
el.dataset.initialized = '1';
|
529 |
+
let marker=null;
|
530 |
+
map.addListener('click',(e)=>{
|
531 |
+
if(marker) marker.setMap(null);
|
532 |
+
marker=new google.maps.Marker({position:e.latLng, map});
|
533 |
+
const latBox=document.querySelector('#lat_box input, #lat_box textarea, #lat_box input[type=number]');
|
534 |
+
const lngBox=document.querySelector('#lng_box input, #lng_box textarea, #lng_box input[type=number]');
|
535 |
+
if(latBox){ latBox.value=e.latLng.lat(); latBox.dispatchEvent(new Event('input',{bubbles:true})); }
|
536 |
+
if(lngBox){ lngBox.value=e.latLng.lng(); lngBox.dispatchEvent(new Event('input',{bubbles:true})); }
|
537 |
+
});
|
538 |
+
setTimeout(() => { try { google.maps.event.trigger(map, 'resize'); map.setCenter({ lat: 0, lng: 0 }); } catch(_) {} }, 150);
|
539 |
+
log('Mini-map initialized');
|
540 |
+
} catch (e) { log('Mini-map init error', e); }
|
541 |
+
});
|
542 |
+
}
|
543 |
+
function initPopupIfPresent() {
|
544 |
+
log('initPopupIfPresent called');
|
545 |
+
const el = document.getElementById('popup-map');
|
546 |
+
if (!el || el.dataset.initialized === '1') return;
|
547 |
+
log("Raw AI dataset values:", { lat: el.dataset.aiLat, lng: el.dataset.aiLng });
|
548 |
+
const rnd = { lat: parseFloat(el.dataset.rndLat), lng: parseFloat(el.dataset.rndLng) };
|
549 |
+
const human = { lat: parseFloat(el.dataset.hLat), lng: parseFloat(el.dataset.hLng) };
|
550 |
+
const ai = { lat: parseFloat(el.dataset.aiLat), lng: parseFloat(el.dataset.aiLng) };
|
551 |
+
log("Parsed AI coords:", ai);
|
552 |
+
ensureMapsLoaded(() => {
|
553 |
+
try {
|
554 |
+
log('Popup map element found, ensuring maps loaded...');
|
555 |
+
const mapOpts={zoom:6,center:rnd,mapTypeControl:false,streetViewControl:false,fullscreenControl:false};
|
556 |
+
const m = new google.maps.Map(el, mapOpts);
|
557 |
+
el.dataset.initialized = '1';
|
558 |
+
const bounds = new google.maps.LatLngBounds();
|
559 |
+
const markerIcon = (fill, stroke) => ({ path: google.maps.SymbolPath.CIRCLE, scale: 9.5, fillColor: fill, fillOpacity: 1, strokeColor: stroke, strokeWeight: 2 });
|
560 |
+
const markerLabel = (text) => ({ text, color: '#ffffff', fontWeight: '700', fontSize: '12px' });
|
561 |
+
log("Marker label style:", markerLabel("A"));
|
562 |
+
const gMk = new google.maps.Marker({ position: rnd, map: m, label: markerLabel('G'), icon: markerIcon('#22c55e', '#166534') });
|
563 |
+
bounds.extend(gMk.getPosition());
|
564 |
+
if (Number.isFinite(ai.lat) && Number.isFinite(ai.lng)) {
|
565 |
+
log("AI coordinates are valid, creating marker.");
|
566 |
+
const aMk = new google.maps.Marker({ position: ai, map: m, label: markerLabel('A'), icon: markerIcon('#2563EB', '#1e3a8a') });
|
567 |
+
bounds.extend(aMk.getPosition());
|
568 |
+
new google.maps.Polyline({ path: [rnd, ai], geodesic: true, strokeColor: '#2563EB', strokeOpacity: 1.0, strokeWeight: 2, map: m });
|
569 |
+
} else {
|
570 |
+
log("AI coordinates are NOT valid, skipping marker creation.");
|
571 |
+
}
|
572 |
+
if (Number.isFinite(human.lat) && Number.isFinite(human.lng)) {
|
573 |
+
const hMk = new google.maps.Marker({ position: human, map: m, label: markerLabel('H'), icon: markerIcon('#f97316', '#c2410c') });
|
574 |
+
bounds.extend(hMk.getPosition());
|
575 |
+
new google.maps.Polyline({ path: [rnd, human], geodesic: true, strokeColor: '#f97316', strokeOpacity: 1.0, strokeWeight: 2, map: m });
|
576 |
+
}
|
577 |
+
const ne = bounds.getNorthEast();
|
578 |
+
const sw = bounds.getSouthWest();
|
579 |
+
if (ne && sw && ne.equals(sw)) {
|
580 |
+
m.setCenter(ne);
|
581 |
+
log("Setting zoom to 18");
|
582 |
+
m.setZoom(18);
|
583 |
+
} else {
|
584 |
+
log("Fitting map to bounds");
|
585 |
+
m.fitBounds(bounds);
|
586 |
+
}
|
587 |
+
setTimeout(() => {
|
588 |
+
try {
|
589 |
+
google.maps.event.trigger(m, 'resize');
|
590 |
+
const ne2 = bounds.getNorthEast();
|
591 |
+
const sw2 = bounds.getSouthWest();
|
592 |
+
if (ne2 && sw2 && ne2.equals(sw2)) {
|
593 |
+
m.setCenter(ne2);
|
594 |
+
log("Setting zoom to 18 after resize");
|
595 |
+
m.setZoom(18);
|
596 |
+
} else {
|
597 |
+
log("Fitting map to bounds after resize");
|
598 |
+
m.fitBounds(bounds);
|
599 |
+
}
|
600 |
+
} catch (e) { log('Resize error', e); }
|
601 |
+
}, 120);
|
602 |
+
const closeButtons = [document.getElementById('popup-close-next'), document.getElementById('popup-close-next-footer')];
|
603 |
+
closeButtons.forEach((btn) => {
|
604 |
+
if (!btn) return;
|
605 |
+
if (!btn.dataset.bound) {
|
606 |
+
btn.addEventListener('click', () => {
|
607 |
+
const ov = document.getElementById('popup-overlay');
|
608 |
+
if (ov) ov.remove();
|
609 |
+
const nxt = document.getElementById('next_btn');
|
610 |
+
if (nxt) nxt.click();
|
611 |
+
});
|
612 |
+
btn.dataset.bound = '1';
|
613 |
+
}
|
614 |
+
});
|
615 |
+
window.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { const ov = document.getElementById('popup-overlay'); if (ov) ov.remove(); const nxt = document.getElementById('next_btn'); if (nxt) nxt.click(); } }, { once: true });
|
616 |
+
log('Popup map initialized');
|
617 |
+
} catch (e) { log('Popup map init error', e); }
|
618 |
+
});
|
619 |
+
}
|
620 |
+
function initMapControlsIfPresent() {
|
621 |
+
const plusBtn = document.getElementById('map-size-plus');
|
622 |
+
const minusBtn = document.getElementById('map-size-minus');
|
623 |
+
const mapWrap = document.getElementById('mini-map-wrap');
|
624 |
+
if (!plusBtn || !minusBtn || !mapWrap || mapWrap.dataset.controlsInitialized) return;
|
625 |
+
const sizes = [{w: 200, h: 130}, {w: 280, h: 180}, {w: 400, h: 260}, {w: 550, h: 360}];
|
626 |
+
let currentSizeIndex = 1;
|
627 |
+
const updateSize = () => {
|
628 |
+
const newSize = sizes[currentSizeIndex];
|
629 |
+
mapWrap.style.width = newSize.w + 'px';
|
630 |
+
mapWrap.style.height = newSize.h + 'px';
|
631 |
+
if (window._miniMapInstance) {
|
632 |
+
setTimeout(() => {
|
633 |
+
google.maps.event.trigger(window._miniMapInstance, 'resize');
|
634 |
+
}, 300);
|
635 |
+
}
|
636 |
+
log('Map size changed to', newSize);
|
637 |
+
};
|
638 |
+
plusBtn.addEventListener('click', (e) => {
|
639 |
+
e.stopPropagation();
|
640 |
+
if (currentSizeIndex < sizes.length - 1) {
|
641 |
+
currentSizeIndex++;
|
642 |
+
updateSize();
|
643 |
+
}
|
644 |
+
});
|
645 |
+
minusBtn.addEventListener('click', (e) => {
|
646 |
+
e.stopPropagation();
|
647 |
+
if (currentSizeIndex > 0) {
|
648 |
+
currentSizeIndex--;
|
649 |
+
updateSize();
|
650 |
+
}
|
651 |
+
});
|
652 |
+
mapWrap.dataset.controlsInitialized = '1';
|
653 |
+
updateSize();
|
654 |
+
log('Map controls initialized');
|
655 |
+
}
|
656 |
+
const obs = new MutationObserver(() => { initMiniMapIfPresent(); initPopupIfPresent(); initMapControlsIfPresent(); });
|
657 |
+
obs.observe(document.documentElement, { childList: true, subtree: true });
|
658 |
+
initMiniMapIfPresent();
|
659 |
+
initPopupIfPresent();
|
660 |
+
initMapControlsIfPresent();
|
661 |
+
}
|
662 |
+
""".replace("__GMAPS_KEY__", GOOGLE_MAPS_API_KEY or '')
|
663 |
+
|
664 |
+
with gr.Blocks(css=APP_CSS, title="LLM GeoGuessr") as demo:
|
665 |
+
user_profile = gr.State()
|
666 |
+
|
667 |
+
with gr.Row():
|
668 |
+
gr.Markdown("## LLM GeoGuessr", elem_id="title_md")
|
669 |
+
gr.Markdown("""
|
670 |
+
### How to Play
|
671 |
+
1. **Select a difficulty** and click "Start Game".
|
672 |
+
2. You'll be shown a random Street View image for 3 rounds.
|
673 |
+
3. Place a marker on the mini-map to guess the location.
|
674 |
+
4. Submit your guess and see how your score compares to the AI's!
|
675 |
+
""")
|
676 |
+
|
677 |
+
login_prompt_md = gr.Markdown("### Please log in with your Hugging Face account to play.", visible=True)
|
678 |
+
login_button = LoginButton(visible=True)
|
679 |
+
|
680 |
+
logged_in_md = gr.Markdown(visible=False)
|
681 |
+
|
682 |
+
with gr.Group(visible=False, elem_id="lobby_group") as lobby_group:
|
683 |
+
with gr.Row():
|
684 |
+
difficulty = gr.Dropdown(choices=["easy", "medium", "hard"], value="easy", label="Difficulty", elem_id="difficulty_select")
|
685 |
+
start_btn = gr.Button("Start Game", variant="primary", elem_id="start_btn")
|
686 |
+
limit_msg = gr.Markdown(visible=False)
|
687 |
+
|
688 |
+
with gr.Group(visible=False, elem_id="game_group") as game_group:
|
689 |
+
rounds_state = gr.State([])
|
690 |
+
idx_state = gr.State(0)
|
691 |
+
round_id_box = gr.Textbox(visible=False)
|
692 |
+
lat_box = gr.Number(visible=True, elem_id="lat_box", label="lat")
|
693 |
+
lng_box = gr.Number(visible=True, elem_id="lng_box", label="lng")
|
694 |
+
street_html = gr.HTML(visible=True)
|
695 |
+
map_html = gr.HTML(visible=False)
|
696 |
+
validate_btn = gr.Button("Validate Guess", visible=True)
|
697 |
+
result_md = gr.Markdown()
|
698 |
+
popup_html = gr.HTML()
|
699 |
+
ai_chat = gr.Textbox(label="AI Analysis", interactive=False, elem_id="ai_chat")
|
700 |
+
next_btn = gr.Button("Next", visible=True, elem_id="next_btn")
|
701 |
+
final_md = gr.Markdown(visible=True)
|
702 |
+
|
703 |
+
def on_login(token: gr.OAuthToken | None):
|
704 |
+
if not token:
|
705 |
+
return (
|
706 |
+
None,
|
707 |
+
gr.update(visible=True),
|
708 |
+
gr.update(visible=True),
|
709 |
+
gr.update(visible=False),
|
710 |
+
gr.update(visible=False),
|
711 |
+
gr.update(),
|
712 |
+
gr.update(visible=False),
|
713 |
+
)
|
714 |
+
|
715 |
+
try:
|
716 |
+
profile = whoami(token=token.token)
|
717 |
+
username = profile["name"]
|
718 |
+
todays_games = data_manager.get_todays_games(token=token.token)
|
719 |
+
has_played = data_manager.has_user_played_today(username, todays_games)
|
720 |
+
except Exception as e:
|
721 |
+
gr.Warning(f"Could not check your game status. Please try again. Error: {e}")
|
722 |
+
return (
|
723 |
+
None,
|
724 |
+
gr.update(visible=True),
|
725 |
+
gr.update(visible=True),
|
726 |
+
gr.update(visible=False),
|
727 |
+
gr.update(visible=False),
|
728 |
+
gr.update(),
|
729 |
+
gr.update(visible=False),
|
730 |
+
)
|
731 |
+
|
732 |
+
welcome_message = f"Welcome, **{profile.get('fullname', username)}**! You are logged in as **{username}**."
|
733 |
+
|
734 |
+
updates = [
|
735 |
+
profile,
|
736 |
+
gr.update(visible=False),
|
737 |
+
gr.update(visible=False),
|
738 |
+
gr.update(visible=True, value=welcome_message),
|
739 |
+
gr.update(visible=True),
|
740 |
+
]
|
741 |
+
|
742 |
+
if has_played:
|
743 |
+
limit_message = "You have already played today. Please come back tomorrow for a new challenge!"
|
744 |
+
updates.extend([
|
745 |
+
gr.update(interactive=False),
|
746 |
+
gr.update(visible=True, value=limit_message),
|
747 |
+
])
|
748 |
+
else:
|
749 |
+
updates.extend([
|
750 |
+
gr.update(interactive=True),
|
751 |
+
gr.update(visible=False),
|
752 |
+
])
|
753 |
+
|
754 |
+
return tuple(updates)
|
755 |
+
|
756 |
+
# Use demo.load to set the initial UI state when the app loads with an existing token.
|
757 |
+
# This is the key fix for the UI flickering issue.
|
758 |
+
demo.load(
|
759 |
+
on_login,
|
760 |
+
outputs=[
|
761 |
+
user_profile,
|
762 |
+
login_prompt_md,
|
763 |
+
login_button,
|
764 |
+
logged_in_md,
|
765 |
+
lobby_group,
|
766 |
+
start_btn,
|
767 |
+
limit_msg,
|
768 |
+
],
|
769 |
+
)
|
770 |
+
|
771 |
+
# The click handler is still needed to initiate the login flow if the user is not logged in.
|
772 |
+
login_button.click(
|
773 |
+
on_login,
|
774 |
+
outputs=[
|
775 |
+
user_profile,
|
776 |
+
login_prompt_md,
|
777 |
+
login_button,
|
778 |
+
logged_in_md,
|
779 |
+
lobby_group,
|
780 |
+
start_btn,
|
781 |
+
limit_msg,
|
782 |
+
],
|
783 |
+
)
|
784 |
+
|
785 |
+
def start_click(difficulty: str, profile: dict, request: gr.Request):
|
786 |
+
if not profile:
|
787 |
+
gr.Warning("Please log in before starting the game.")
|
788 |
+
# Return no-ops for all outputs to prevent errors
|
789 |
+
return None, 0, "", "", "", "", gr.update(), gr.update(), gr.update()
|
790 |
+
|
791 |
+
r, idx, rid, s_html, m_html, err = gr_start_game(difficulty, profile["name"], request)
|
792 |
+
return (
|
793 |
+
r,
|
794 |
+
idx,
|
795 |
+
rid,
|
796 |
+
s_html,
|
797 |
+
m_html,
|
798 |
+
gr.update(value=""),
|
799 |
+
gr.update(visible=True),
|
800 |
+
gr.update(visible=False),
|
801 |
+
gr.update(value="")
|
802 |
+
)
|
803 |
+
|
804 |
+
start_btn.click(
|
805 |
+
start_click,
|
806 |
+
inputs=[difficulty, user_profile],
|
807 |
+
outputs=[rounds_state, idx_state, round_id_box, street_html, map_html, result_md, game_group, lobby_group, limit_msg]
|
808 |
+
)
|
809 |
+
|
810 |
+
def on_validate(rid, lat, lng, profile: dict, request: gr.Request):
|
811 |
+
if not profile: return
|
812 |
+
username = profile["name"]
|
813 |
+
_popup, txt, a_lat, a_lng, _score = gr_submit_guess(rid, lat, lng, username, request)
|
814 |
+
yield "", txt + "\n\n[AI] Analyzing image..."
|
815 |
+
|
816 |
+
rnd = get_round(username, rid)
|
817 |
+
if not rnd:
|
818 |
+
yield "", txt + "\n\n[Error] Round not found"
|
819 |
+
return
|
820 |
+
try:
|
821 |
+
img_resp = requests.get(rnd['image_url'])
|
822 |
+
img_resp.raise_for_status()
|
823 |
+
last_text = ""
|
824 |
+
for partial_text in llm_stream_image_text(img_resp.content):
|
825 |
+
last_text = partial_text
|
826 |
+
yield "", txt + "\n\n" + partial_text
|
827 |
+
# After stream completes, compute AI guess and score
|
828 |
+
ai_coords = extract_coords_from_text(last_text) or geocode_text_to_coords(last_text[-256:])
|
829 |
+
if ai_coords:
|
830 |
+
rnd['ai_guess'] = ai_coords
|
831 |
+
ai_dist_km = haversine_km(rnd['lat'], rnd['lng'], ai_coords['lat'], ai_coords['lng'])
|
832 |
+
rnd['ai_distance_km'] = float(ai_dist_km)
|
833 |
+
rnd['ai_score'] = score_from_distance_km(ai_dist_km)
|
834 |
+
except Exception as e:
|
835 |
+
yield "", txt + f"\n\n[Error] {e}"
|
836 |
+
return
|
837 |
+
|
838 |
+
# 3) Show popup with both guesses and update scoreboard
|
839 |
+
sess = user_sessions.get(username, {})
|
840 |
+
total_human = sum(float(r.get('human_score', 0.0)) for r in sess.get('rounds', []))
|
841 |
+
total_ai = sum(float(r.get('ai_score', 0.0)) for r in sess.get('rounds', []))
|
842 |
+
round_idx = next((i for i, rr in enumerate(sess.get('rounds', [])) if rr['id'] == rid), 0) + 1
|
843 |
+
# Escape AI analysis to display safely inside HTML
|
844 |
+
import html as _html
|
845 |
+
ai_text_safe = _html.escape(last_text or "")
|
846 |
+
summary_safe = _html.escape(txt)
|
847 |
+
scoreboard_html = (
|
848 |
+
f"<div style='margin:8px 0;padding:24px;background:#09101f;border-radius:18px;color:#f9fafb;border:1px solid rgba(148,163,184,0.35);box-shadow:0 22px 60px rgba(8,12,24,0.6);'>"
|
849 |
+
f"<div style='font-weight:700;font-size:1.25rem;margin-bottom:12px;text-shadow:0 0 12px rgba(8,12,24,0.8);'>Round {round_idx}</div>"
|
850 |
+
f"<div style='margin-bottom:18px;color:#f8fafc;font-size:1rem;line-height:1.55;text-shadow:0 0 10px rgba(8,12,24,0.75);'>{summary_safe}</div>"
|
851 |
+
f"<div style='display:flex;gap:26px;font-size:0.98rem;margin-bottom:18px;flex-wrap:wrap;'>"
|
852 |
+
f" <span><strong>Human:</strong> {rnd.get('human_score',0):.0f} pts <span style='color:#dbeafe;'>({rnd.get('human_distance_km',0.0):.1f} km)</span></span>"
|
853 |
+
f" <span><strong>AI:</strong> {rnd.get('ai_score',0):.0f} pts <span style='color:#dbeafe;'>({rnd.get('ai_distance_km',0.0):.1f} km)</span></span>"
|
854 |
+
f"</div>"
|
855 |
+
f"<div style='margin-bottom:16px;font-weight:600;font-size:1rem;color:#e2e8f0;text-shadow:0 0 10px rgba(8,12,24,0.65);'>Totals — Human {total_human:.0f} / AI {total_ai:.0f}</div>"
|
856 |
+
f"<div id='ai-analysis-box' style='background:#f1f5f9; border-radius:14px;border:1px solid rgba(148,163,184,0.45);padding:16px;max-height:280px;overflow:auto;'>"
|
857 |
+
f" <div style='margin-bottom:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;'>AI Analysis</div>"
|
858 |
+
f" <pre style='margin:0;background:transparent;color:inherit;font-size:1rem;white-space:pre-wrap;line-height:1.6;font-family:\"SFMono-Regular\",Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;'>" + ai_text_safe + "</pre>"
|
859 |
+
f"</div>"
|
860 |
+
f"</div>"
|
861 |
+
)
|
862 |
+
|
863 |
+
ai = rnd.get('ai_guess')
|
864 |
+
ai_lat_str = ""
|
865 |
+
ai_lng_str = ""
|
866 |
+
if ai:
|
867 |
+
ai_lat = float(ai.get('lat', 0.0))
|
868 |
+
ai_lng = float(ai.get('lng', 0.0))
|
869 |
+
ai_lat_str = str(ai_lat)
|
870 |
+
ai_lng_str = str(ai_lng)
|
871 |
+
|
872 |
+
popup_html = """
|
873 |
+
<div id="popup-overlay" style="position:fixed;inset:0;background:rgba(8,11,20,0.78);display:flex;align-items:center;justify-content:center;z-index:1000;">
|
874 |
+
<div id="popup-card" style="background:#04070f;padding:26px;border-radius:18px;width:92%;max-width:960px;color:#f9fafb;position:relative;box-shadow:0 28px 80px rgba(4,7,15,0.75);border:1px solid rgba(148,163,184,0.35);" onclick="event.stopPropagation();">
|
875 |
+
<button id="popup-close-next" style="position:absolute;top:16px;right:18px;padding:10px 18px;border-radius:999px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 8px 18px rgba(37,99,235,0.45);">Close & Next</button>
|
876 |
+
<h3 style="margin:0 0 20px;color:#f8fafc;text-shadow:0 0 14px rgba(37,99,235,0.35);">Round Results</h3>
|
877 |
+
__SCOREBOARD__
|
878 |
+
<div id="popup-map" data-rnd-lat="__RND_LAT__" data-rnd-lng="__RND_LNG__" data-h-lat="__H_LAT__" data-h-lng="__H_LNG__" data-ai-lat="__AI_LAT__" data-ai-lng="__AI_LNG__" style="width:100%;height:440px;border-radius:16px;overflow:hidden;border:1px solid rgba(148,163,184,0.35);box-shadow:0 16px 40px rgba(4,7,15,0.72);"></div>
|
879 |
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:0.98rem;color:#e2e8f0;flex-wrap:wrap;gap:14px;">
|
880 |
+
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;">
|
881 |
+
<span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,0.6);"></span>G = Ground Truth</span>
|
882 |
+
<span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#f97316;box-shadow:0 0 8px rgba(249,115,22,0.55);"></span>H = Human</span>
|
883 |
+
<span style="display:flex;align-items:center;gap:8px;"><span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#2563EB;box-shadow:0 0 8px rgba(37,99,235,0.6);"></span>A = AI</span>
|
884 |
+
</div>
|
885 |
+
<button id="popup-close-next-footer" style="padding:12px 22px;border-radius:12px;border:1px solid rgba(96,165,250,0.4);background:linear-gradient(135deg,#1d4ed8,#2563eb);color:#f8fafc;font-weight:600;cursor:pointer;box-shadow:0 10px 22px rgba(37,99,235,0.48);">Next Round</button>
|
886 |
+
</div>
|
887 |
+
</div>
|
888 |
+
</div>
|
889 |
+
"""
|
890 |
+
popup_html = popup_html.replace('__SCOREBOARD__', scoreboard_html) \
|
891 |
+
.replace('__RND_LAT__', str(rnd['lat'])) \
|
892 |
+
.replace('__RND_LNG__', str(rnd['lng'])) \
|
893 |
+
.replace('__H_LAT__', str(float(lat))) \
|
894 |
+
.replace('__H_LNG__', str(float(lng))) \
|
895 |
+
.replace('__AI_LAT__', ai_lat_str) \
|
896 |
+
.replace('__AI_LNG__', ai_lng_str) \
|
897 |
+
.replace('__GMAPS_KEY__', GOOGLE_MAPS_API_KEY or '')
|
898 |
+
|
899 |
+
yield popup_html, (txt + ("\n\n" + (last_text or "")))
|
900 |
+
|
901 |
+
validate_btn.click(on_validate, inputs=[round_id_box, lat_box, lng_box, user_profile], outputs=[popup_html, ai_chat])
|
902 |
+
|
903 |
+
def on_next(r_state: list, idx: int, profile: dict, request: gr.Request):
|
904 |
+
if not profile: return
|
905 |
+
username = profile["name"]
|
906 |
+
idx += 1
|
907 |
+
sess = user_sessions.get(username)
|
908 |
+
if not sess or idx >= len(sess['rounds']):
|
909 |
+
total_human = sum(float(r.get('human_score', 0.0)) for r in sess.get('rounds', []))
|
910 |
+
total_ai = sum(float(r.get('ai_score', 0.0)) for r in sess.get('rounds', []))
|
911 |
+
|
912 |
+
data_manager.record_game(username, total_human)
|
913 |
+
|
914 |
+
winner_message = "It's a tie!"
|
915 |
+
if total_human > total_ai:
|
916 |
+
winner_message = "Congratulations, you won!"
|
917 |
+
elif total_ai > total_human:
|
918 |
+
winner_message = "The AI won this time."
|
919 |
+
|
920 |
+
summary_html = f"""
|
921 |
+
<div style='text-align:center; padding: 40px; font-size: 1.2em;'>
|
922 |
+
<h2>Game Over!</h2>
|
923 |
+
<p>Here are the final scores:</p>
|
924 |
+
<p><strong>Your Score:</strong> {total_human:.0f}</p>
|
925 |
+
<p><strong>AI's Score:</strong> {total_ai:.0f}</p>
|
926 |
+
<h3>{winner_message}</h3>
|
927 |
+
</div>
|
928 |
+
"""
|
929 |
+
return idx, gr.update(value=summary_html), gr.update(value=""), gr.update(value="")
|
930 |
+
r = sess['rounds'][idx]
|
931 |
+
s_html = build_street_html(r['image_url'])
|
932 |
+
return idx, gr.update(value=s_html), gr.update(value=r['id']), gr.update(value="")
|
933 |
+
|
934 |
+
next_btn.click(on_next, inputs=[rounds_state, idx_state, user_profile], outputs=[idx_state, street_html, round_id_box, popup_html])
|
935 |
+
|
936 |
+
# Inject boot JS using load(js=callable) compatible format
|
937 |
+
demo.load(fn=lambda: None, inputs=None, outputs=None, js=APP_BOOT_JS)
|
938 |
+
|
939 |
+
|
940 |
+
if __name__ == "__main__":
|
941 |
+
demo.queue().launch(server_name="0.0.0.0", server_port=7860)
|
data_manager.py
ADDED
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import tempfile
|
4 |
+
from datetime import datetime, timezone
|
5 |
+
from huggingface_hub import hf_hub_download, upload_file
|
6 |
+
from huggingface_hub.utils import HfHubHTTPError
|
7 |
+
|
8 |
+
# Constant for the dataset repository, configurable via environment variable
|
9 |
+
DATASET_REPO = os.getenv("HF_DATASET_REPO", "jofthomas/geoguessr_game_of_the_day")
|
10 |
+
|
11 |
+
def get_todays_records_path() -> str:
|
12 |
+
"""Gets the path for today's game records file, e.g., 'records/2025-10-03.json'."""
|
13 |
+
date_str = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
14 |
+
return f"records/{date_str}.json"
|
15 |
+
|
16 |
+
def get_todays_games(token: str) -> list:
|
17 |
+
"""
|
18 |
+
Downloads and reads the game records for the current day from the HF Hub.
|
19 |
+
Returns an empty list if the file for today doesn't exist yet.
|
20 |
+
"""
|
21 |
+
filepath = get_todays_records_path()
|
22 |
+
try:
|
23 |
+
# Use the provided token for read access
|
24 |
+
local_path = hf_hub_download(
|
25 |
+
repo_id=DATASET_REPO,
|
26 |
+
filename=filepath,
|
27 |
+
repo_type="dataset",
|
28 |
+
token=token,
|
29 |
+
)
|
30 |
+
with open(local_path, "r", encoding="utf-8") as f:
|
31 |
+
return json.load(f)
|
32 |
+
except HfHubHTTPError as e:
|
33 |
+
if e.response.status_code == 404:
|
34 |
+
return [] # No games played today yet, which is normal.
|
35 |
+
else:
|
36 |
+
print(f"Error downloading daily records: {e}")
|
37 |
+
raise # Re-raise other HTTP errors
|
38 |
+
except Exception as e:
|
39 |
+
print(f"An unexpected error occurred while getting today's games: {e}")
|
40 |
+
return []
|
41 |
+
|
42 |
+
def has_user_played_today(username: str, todays_games: list) -> bool:
|
43 |
+
"""Checks if a user's record already exists in today's games."""
|
44 |
+
return any(game.get("username") == username for game in todays_games)
|
45 |
+
|
46 |
+
def record_game(username: str, score: float):
|
47 |
+
"""
|
48 |
+
Records a completed game to the daily records file on the HF Hub.
|
49 |
+
This function reads the existing file, appends the new record, and uploads it back.
|
50 |
+
It uses the server's write token from environment variables.
|
51 |
+
"""
|
52 |
+
write_token = os.getenv("HF_TOKEN", "")
|
53 |
+
if not write_token:
|
54 |
+
print("Warning: Server HF_TOKEN not set. Cannot record game score.")
|
55 |
+
return
|
56 |
+
|
57 |
+
try:
|
58 |
+
# Fetch the latest records using the write token to ensure we have the most recent data
|
59 |
+
todays_games = get_todays_games(token=write_token)
|
60 |
+
|
61 |
+
# Final check to prevent duplicate entries in case of concurrent games
|
62 |
+
if has_user_played_today(username, todays_games):
|
63 |
+
print(f"User {username} has already played today. Skipping record.")
|
64 |
+
return
|
65 |
+
|
66 |
+
todays_games.append({
|
67 |
+
"username": username,
|
68 |
+
"score": score,
|
69 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
70 |
+
})
|
71 |
+
|
72 |
+
filepath_in_repo = get_todays_records_path()
|
73 |
+
|
74 |
+
with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json", encoding="utf-8") as tmp_file:
|
75 |
+
json.dump(todays_games, tmp_file, indent=2)
|
76 |
+
tmp_file_path = tmp_file.name
|
77 |
+
|
78 |
+
upload_file(
|
79 |
+
path_or_fileobj=tmp_file_path,
|
80 |
+
path_in_repo=filepath_in_repo,
|
81 |
+
repo_id=DATASET_REPO,
|
82 |
+
repo_type="dataset",
|
83 |
+
token=write_token,
|
84 |
+
commit_message=f"Game result for {username}"
|
85 |
+
)
|
86 |
+
print(f"Successfully recorded game for {username} with score {score}")
|
87 |
+
|
88 |
+
except Exception as e:
|
89 |
+
print(f"Error recording game for {username}: {e}")
|
90 |
+
finally:
|
91 |
+
if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
|
92 |
+
os.remove(tmp_file_path)
|
reference.py
ADDED
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import spaces
|
3 |
+
import torch
|
4 |
+
from huggingface_hub import hf_hub_download
|
5 |
+
from transformers import Mistral3ForConditionalGeneration, AutoTokenizer
|
6 |
+
from typing import Any, List, Dict
|
7 |
+
import base64
|
8 |
+
import mimetypes
|
9 |
+
from pathlib import Path
|
10 |
+
|
11 |
+
def load_system_prompt(repo_id: str, filename: str) -> dict[str, Any]:
|
12 |
+
file_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
13 |
+
with open(file_path, "r") as file:
|
14 |
+
system_prompt = file.read()
|
15 |
+
|
16 |
+
index_begin_think = system_prompt.find("[THINK]")
|
17 |
+
index_end_think = system_prompt.find("[/THINK]")
|
18 |
+
|
19 |
+
return {
|
20 |
+
"role": "system",
|
21 |
+
"content": [
|
22 |
+
{"type": "text", "text": system_prompt[:index_begin_think]},
|
23 |
+
{
|
24 |
+
"type": "text",
|
25 |
+
"text": system_prompt[index_end_think + len("[/THINK]") :],
|
26 |
+
},
|
27 |
+
],
|
28 |
+
}
|
29 |
+
|
30 |
+
model_id = "mistralai/Magistral-Small-2509"
|
31 |
+
tokenizer = AutoTokenizer.from_pretrained(model_id, tokenizer_type="mistral")
|
32 |
+
model = Mistral3ForConditionalGeneration.from_pretrained(
|
33 |
+
model_id, torch_dtype=torch.bfloat16, device_map="auto"
|
34 |
+
).eval()
|
35 |
+
|
36 |
+
|
37 |
+
SYSTEM_PROMPT = load_system_prompt(model_id, "SYSTEM_PROMPT.txt")
|
38 |
+
|
39 |
+
@spaces.GPU(duration=120)
|
40 |
+
def predict(message: dict, history: list) -> str:
|
41 |
+
# Build messages for the model from history
|
42 |
+
messages = [SYSTEM_PROMPT]
|
43 |
+
for user_msg, assistant_msg in history:
|
44 |
+
messages.append({"role": "user", "content": [{"type": "text", "text": user_msg}]})
|
45 |
+
if assistant_msg:
|
46 |
+
messages.append({"role": "assistant", "content": [{"type": "text", "text": assistant_msg}]})
|
47 |
+
|
48 |
+
# Process current user message (with potential image)
|
49 |
+
user_content = [{"type": "text", "text": message['text']}]
|
50 |
+
if message['files']:
|
51 |
+
# Assuming one image file from multimodal textbox
|
52 |
+
image_path = Path(message['files'][0])
|
53 |
+
image_bytes = image_path.read_bytes()
|
54 |
+
encoded_image = base64.b64encode(image_bytes).decode("utf-8")
|
55 |
+
mime_type, _ = mimetypes.guess_type(image_path)
|
56 |
+
if mime_type is None:
|
57 |
+
mime_type = "image/png"
|
58 |
+
data_url = f"data:{mime_type};base64,{encoded_image}"
|
59 |
+
user_content.append({"type": "image_url", "image_url": {"url": data_url}})
|
60 |
+
|
61 |
+
messages.append({"role": "user", "content": user_content})
|
62 |
+
|
63 |
+
tokenized = tokenizer.apply_chat_template(messages, return_dict=True)
|
64 |
+
|
65 |
+
input_ids = torch.tensor(tokenized.input_ids, device="cuda").unsqueeze(0)
|
66 |
+
attention_mask = torch.tensor(tokenized.attention_mask, device="cuda").unsqueeze(0)
|
67 |
+
|
68 |
+
if 'pixel_values' in tokenized and len(tokenized.pixel_values) > 0:
|
69 |
+
pixel_values = torch.tensor(
|
70 |
+
tokenized.pixel_values[0], dtype=torch.bfloat16, device="cuda"
|
71 |
+
).unsqueeze(0)
|
72 |
+
image_sizes = torch.tensor(pixel_values.shape[-2:], device="cuda").unsqueeze(0)
|
73 |
+
output = model.generate(
|
74 |
+
input_ids=input_ids,
|
75 |
+
attention_mask=attention_mask,
|
76 |
+
pixel_values=pixel_values,
|
77 |
+
image_sizes=image_sizes,
|
78 |
+
)[0]
|
79 |
+
else:
|
80 |
+
output = model.generate(
|
81 |
+
input_ids=input_ids,
|
82 |
+
attention_mask=attention_mask,
|
83 |
+
)[0]
|
84 |
+
|
85 |
+
decoded_output = tokenizer.decode(
|
86 |
+
output[
|
87 |
+
len(tokenized.input_ids) : (
|
88 |
+
-1 if output[-1] == tokenizer.eos_token_id else len(output)
|
89 |
+
)
|
90 |
+
]
|
91 |
+
)
|
92 |
+
return decoded_output
|
93 |
+
|
94 |
+
demo = gr.ChatInterface(
|
95 |
+
fn=predict,
|
96 |
+
multimodal=True,
|
97 |
+
title="Magistral Chat App",
|
98 |
+
description='Chat with Magistral AI. Upload an image if relevant to your question.<br>Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank">anycoder</a>',
|
99 |
+
)
|
100 |
+
|
101 |
+
if __name__ == "__main__":
|
102 |
+
demo.launch()
|
requirements.txt
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Flask
|
2 |
+
python-dotenv
|
3 |
+
requests
|
4 |
+
mcp[cli]
|
5 |
+
huggingface_hub
|
6 |
+
transformers[mistral-common]
|
7 |
+
gradio
|
8 |
+
torch
|
9 |
+
sentencepiece
|
10 |
+
spaces
|
11 |
+
accelerate
|
12 |
+
tokenizers
|
static/admin.js
ADDED
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
let map;
|
2 |
+
let drawingManager;
|
3 |
+
let lastDrawnShape = null;
|
4 |
+
let displayedShapes = [];
|
5 |
+
|
6 |
+
const difficultyColors = {
|
7 |
+
easy: '#34A853', // Green
|
8 |
+
medium: '#F9AB00', // Yellow
|
9 |
+
hard: '#EA4335' // Red
|
10 |
+
};
|
11 |
+
|
12 |
+
function initAdminMap() {
|
13 |
+
map = new google.maps.Map(document.getElementById('map'), {
|
14 |
+
center: { lat: 20, lng: 0 },
|
15 |
+
zoom: 2,
|
16 |
+
});
|
17 |
+
|
18 |
+
drawingManager = new google.maps.drawing.DrawingManager({
|
19 |
+
drawingMode: google.maps.drawing.OverlayType.RECTANGLE,
|
20 |
+
drawingControl: true,
|
21 |
+
drawingControlOptions: {
|
22 |
+
position: google.maps.ControlPosition.TOP_CENTER,
|
23 |
+
drawingModes: [google.maps.drawing.OverlayType.RECTANGLE],
|
24 |
+
},
|
25 |
+
rectangleOptions: {
|
26 |
+
fillColor: '#F97316',
|
27 |
+
fillOpacity: 0.3,
|
28 |
+
strokeWeight: 1,
|
29 |
+
clickable: true,
|
30 |
+
editable: true,
|
31 |
+
zIndex: 1,
|
32 |
+
},
|
33 |
+
});
|
34 |
+
|
35 |
+
drawingManager.setMap(map);
|
36 |
+
|
37 |
+
google.maps.event.addListener(drawingManager, 'overlaycomplete', function(event) {
|
38 |
+
if (lastDrawnShape) {
|
39 |
+
lastDrawnShape.setMap(null);
|
40 |
+
}
|
41 |
+
lastDrawnShape = event.overlay;
|
42 |
+
drawingManager.setDrawingMode(null); // Exit drawing mode
|
43 |
+
document.getElementById('save-zone').disabled = false;
|
44 |
+
});
|
45 |
+
|
46 |
+
document.getElementById('save-zone').addEventListener('click', saveLastZone);
|
47 |
+
document.getElementById('new-zone-btn').addEventListener('click', () => {
|
48 |
+
drawingManager.setDrawingMode(google.maps.drawing.OverlayType.RECTANGLE);
|
49 |
+
document.getElementById('save-zone').disabled = true;
|
50 |
+
});
|
51 |
+
|
52 |
+
loadExistingZones();
|
53 |
+
}
|
54 |
+
|
55 |
+
function saveLastZone() {
|
56 |
+
if (!lastDrawnShape) {
|
57 |
+
alert('Please draw a zone first.');
|
58 |
+
return;
|
59 |
+
}
|
60 |
+
|
61 |
+
const difficulty = document.getElementById('difficulty-select').value;
|
62 |
+
const bounds = lastDrawnShape.getBounds().toJSON();
|
63 |
+
|
64 |
+
const zoneData = {
|
65 |
+
type: 'rectangle',
|
66 |
+
bounds: bounds,
|
67 |
+
};
|
68 |
+
|
69 |
+
fetch('/api/zones', {
|
70 |
+
method: 'POST',
|
71 |
+
headers: { 'Content-Type': 'application/json' },
|
72 |
+
body: JSON.stringify({ difficulty: difficulty, zone: zoneData }),
|
73 |
+
})
|
74 |
+
.then(response => response.json())
|
75 |
+
.then(data => {
|
76 |
+
const statusMsg = document.getElementById('status-message');
|
77 |
+
statusMsg.textContent = data.message || `Error: ${data.error}`;
|
78 |
+
setTimeout(() => statusMsg.textContent = '', 3000);
|
79 |
+
|
80 |
+
// Clean up the drawn shape and reload all zones to get the new one with its listener
|
81 |
+
if (lastDrawnShape) {
|
82 |
+
lastDrawnShape.setMap(null);
|
83 |
+
lastDrawnShape = null;
|
84 |
+
}
|
85 |
+
document.getElementById('save-zone').disabled = true;
|
86 |
+
loadExistingZones();
|
87 |
+
});
|
88 |
+
}
|
89 |
+
|
90 |
+
function loadExistingZones() {
|
91 |
+
// Clear existing shapes from the map
|
92 |
+
displayedShapes.forEach(shape => shape.setMap(null));
|
93 |
+
displayedShapes = [];
|
94 |
+
|
95 |
+
fetch('/api/zones')
|
96 |
+
.then(response => response.json())
|
97 |
+
.then(zones => {
|
98 |
+
for (const difficulty in zones) {
|
99 |
+
zones[difficulty].forEach(zone => {
|
100 |
+
if (zone.type === 'rectangle') {
|
101 |
+
const rectangle = new google.maps.Rectangle({
|
102 |
+
bounds: zone.bounds,
|
103 |
+
map: map,
|
104 |
+
fillColor: difficultyColors[difficulty],
|
105 |
+
fillOpacity: 0.35,
|
106 |
+
strokeColor: difficultyColors[difficulty],
|
107 |
+
strokeWeight: 2,
|
108 |
+
editable: false,
|
109 |
+
clickable: true,
|
110 |
+
});
|
111 |
+
|
112 |
+
rectangle.zoneId = zone.id;
|
113 |
+
|
114 |
+
google.maps.event.addListener(rectangle, 'click', function() {
|
115 |
+
if (confirm('Are you sure you want to delete this zone?')) {
|
116 |
+
deleteZone(this.zoneId, this);
|
117 |
+
}
|
118 |
+
});
|
119 |
+
|
120 |
+
displayedShapes.push(rectangle);
|
121 |
+
}
|
122 |
+
});
|
123 |
+
}
|
124 |
+
});
|
125 |
+
}
|
126 |
+
|
127 |
+
function deleteZone(zoneId, shape) {
|
128 |
+
fetch('/api/zones', {
|
129 |
+
method: 'DELETE',
|
130 |
+
headers: { 'Content-Type': 'application/json' },
|
131 |
+
body: JSON.stringify({ zone_id: zoneId })
|
132 |
+
})
|
133 |
+
.then(response => response.json())
|
134 |
+
.then(data => {
|
135 |
+
const statusMsg = document.getElementById('status-message');
|
136 |
+
statusMsg.textContent = data.message || `Error: ${data.error}`;
|
137 |
+
setTimeout(() => statusMsg.textContent = '', 3000);
|
138 |
+
|
139 |
+
if (data.message) {
|
140 |
+
shape.setMap(null);
|
141 |
+
}
|
142 |
+
});
|
143 |
+
}
|
static/script.js
ADDED
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
let miniMap, guessMarker, currentRounds = [], currentRoundIdx = 0, aiEventSource = null;
|
2 |
+
|
3 |
+
function initIndexApp() {
|
4 |
+
hydrateAuth();
|
5 |
+
document.getElementById('start-btn').addEventListener('click', async () => {
|
6 |
+
const difficulty = document.getElementById('difficulty-select-lobby').value;
|
7 |
+
const url = new URL(window.location.origin + '/game');
|
8 |
+
url.searchParams.set('difficulty', difficulty);
|
9 |
+
window.location.href = url.toString();
|
10 |
+
});
|
11 |
+
}
|
12 |
+
|
13 |
+
async function initGameApp() {
|
14 |
+
const params = new URLSearchParams(window.location.search);
|
15 |
+
const difficulty = params.get('difficulty') || 'easy';
|
16 |
+
const res = await fetch('/api/start', {
|
17 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ difficulty })
|
18 |
+
});
|
19 |
+
const data = await res.json();
|
20 |
+
if (!res.ok) { alert(data.error || 'Failed to start game'); window.location.href = '/'; return; }
|
21 |
+
currentRounds = data.rounds;
|
22 |
+
currentRoundIdx = 0;
|
23 |
+
document.getElementById('game-container').style.display = 'block';
|
24 |
+
document.getElementById('chat-container').style.display = 'none';
|
25 |
+
document.getElementById('validate-btn').addEventListener('click', validateGuess);
|
26 |
+
document.getElementById('next-round-btn').addEventListener('click', nextRound);
|
27 |
+
const wrapEl = document.getElementById('mini-map-wrap');
|
28 |
+
document.getElementById('map-size-plus').addEventListener('click', (e) => { e.stopPropagation(); resizeMiniMap(wrapEl, 1); });
|
29 |
+
document.getElementById('map-size-minus').addEventListener('click', (e) => { e.stopPropagation(); resizeMiniMap(wrapEl, -1); });
|
30 |
+
showRound();
|
31 |
+
}
|
32 |
+
|
33 |
+
async function hydrateAuth() {
|
34 |
+
const res = await fetch('/api/me');
|
35 |
+
const me = await res.json();
|
36 |
+
const signedin = document.getElementById('signedin');
|
37 |
+
const signinBtn = document.getElementById('signin-btn');
|
38 |
+
const startBtn = document.getElementById('start-btn');
|
39 |
+
const limitMsg = document.getElementById('limit-msg');
|
40 |
+
if (me.authenticated) {
|
41 |
+
signinBtn.style.display = 'none';
|
42 |
+
signedin.style.display = 'block';
|
43 |
+
document.getElementById('username').textContent = me.username;
|
44 |
+
if (me.can_play_today) {
|
45 |
+
startBtn.disabled = false;
|
46 |
+
limitMsg.style.display = 'none';
|
47 |
+
} else {
|
48 |
+
startBtn.disabled = true;
|
49 |
+
limitMsg.style.display = 'block';
|
50 |
+
limitMsg.textContent = `You already played today. Try again in ${formatCountdown(me.seconds_until_midnight)}.`;
|
51 |
+
startCountdown(limitMsg, me.seconds_until_midnight);
|
52 |
+
}
|
53 |
+
} else {
|
54 |
+
signinBtn.style.display = 'inline-block';
|
55 |
+
signedin.style.display = 'none';
|
56 |
+
startBtn.disabled = true;
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
function formatCountdown(seconds) {
|
61 |
+
const h = Math.floor(seconds / 3600);
|
62 |
+
const m = Math.floor((seconds % 3600) / 60);
|
63 |
+
const s = seconds % 60;
|
64 |
+
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
65 |
+
}
|
66 |
+
|
67 |
+
function pad(n) { return n.toString().padStart(2, '0'); }
|
68 |
+
|
69 |
+
function startCountdown(el, seconds) {
|
70 |
+
let remaining = seconds;
|
71 |
+
const id = setInterval(() => {
|
72 |
+
remaining -= 1;
|
73 |
+
if (remaining <= 0) { clearInterval(id); location.reload(); return; }
|
74 |
+
el.textContent = `You already played today. Try again in ${formatCountdown(remaining)}.`;
|
75 |
+
}, 1000);
|
76 |
+
}
|
77 |
+
|
78 |
+
async function startGame() {
|
79 |
+
const difficulty = document.getElementById('difficulty-select-lobby').value;
|
80 |
+
const res = await fetch('/api/start', {
|
81 |
+
method: 'POST',
|
82 |
+
headers: { 'Content-Type': 'application/json' },
|
83 |
+
body: JSON.stringify({ difficulty })
|
84 |
+
});
|
85 |
+
const data = await res.json();
|
86 |
+
if (!res.ok) {
|
87 |
+
alert(data.error || 'Failed to start game');
|
88 |
+
return;
|
89 |
+
}
|
90 |
+
currentRounds = data.rounds;
|
91 |
+
currentRoundIdx = 0;
|
92 |
+
document.getElementById('game-container').style.display = 'block';
|
93 |
+
document.getElementById('chat-container').style.display = 'none';
|
94 |
+
showRound();
|
95 |
+
}
|
96 |
+
|
97 |
+
function showRound() {
|
98 |
+
const round = currentRounds[currentRoundIdx];
|
99 |
+
const img = document.getElementById('street-image');
|
100 |
+
img.src = round.image_url;
|
101 |
+
document.getElementById('validate-btn').style.display = 'none';
|
102 |
+
initMiniMap();
|
103 |
+
}
|
104 |
+
|
105 |
+
function initMiniMap() {
|
106 |
+
const miniEl = document.getElementById('mini-map');
|
107 |
+
miniMap = new google.maps.Map(miniEl, {
|
108 |
+
center: { lat: 0, lng: 0 },
|
109 |
+
zoom: 1,
|
110 |
+
streetViewControl: false,
|
111 |
+
mapTypeControl: false,
|
112 |
+
fullscreenControl: false,
|
113 |
+
});
|
114 |
+
miniMap.addListener('click', (e) => {
|
115 |
+
if (miniMap._locked) return;
|
116 |
+
placeGuessMarker(e.latLng);
|
117 |
+
document.getElementById('validate-btn').style.display = 'inline-block';
|
118 |
+
});
|
119 |
+
}
|
120 |
+
|
121 |
+
function resizeMiniMap(el, delta) {
|
122 |
+
const sizes = ['size-small', 'size-medium', 'size-large'];
|
123 |
+
let idx = sizes.findIndex(c => el.classList.contains(c));
|
124 |
+
if (idx === -1) idx = 1;
|
125 |
+
idx = Math.min(2, Math.max(0, idx + (delta > 0 ? 1 : -1)));
|
126 |
+
sizes.forEach(c => el.classList.remove(c));
|
127 |
+
el.classList.add(sizes[idx]);
|
128 |
+
if (miniMap) {
|
129 |
+
const miniEl = document.getElementById('mini-map');
|
130 |
+
google.maps.event.trigger(miniMap, 'resize');
|
131 |
+
const center = miniMap.getCenter();
|
132 |
+
miniMap.setCenter(center);
|
133 |
+
}
|
134 |
+
}
|
135 |
+
|
136 |
+
function placeGuessMarker(latLng) {
|
137 |
+
if (guessMarker) guessMarker.setMap(null);
|
138 |
+
guessMarker = new google.maps.Marker({ position: latLng, map: miniMap });
|
139 |
+
miniMap.setCenter(latLng);
|
140 |
+
}
|
141 |
+
|
142 |
+
async function validateGuess() {
|
143 |
+
if (!guessMarker) { alert('Click on the mini-map to pick your guess.'); return; }
|
144 |
+
const round = currentRounds[currentRoundIdx];
|
145 |
+
const pos = guessMarker.getPosition();
|
146 |
+
const payload = { round_id: round.id, lat: pos.lat(), lng: pos.lng() };
|
147 |
+
const res = await fetch('/api/guess', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
148 |
+
const result = await res.json();
|
149 |
+
if (!res.ok) { alert(result.error || 'Guess failed'); return; }
|
150 |
+
miniMap._locked = true;
|
151 |
+
document.getElementById('validate-btn').style.display = 'none';
|
152 |
+
document.getElementById('chat-container').style.display = 'block';
|
153 |
+
const chat = document.getElementById('chat-log');
|
154 |
+
chat.textContent = '';
|
155 |
+
chat.textContent += 'Analyzing image...\n';
|
156 |
+
const aiRes = await fetch('/api/ai_analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ round_id: round.id }) });
|
157 |
+
const aiData = await aiRes.json();
|
158 |
+
if (!aiRes.ok) {
|
159 |
+
chat.textContent += `[Error] ${aiData.error || 'AI failed'}\n`;
|
160 |
+
document.getElementById('next-round-btn').disabled = false;
|
161 |
+
openResultsPopup({ actual: result.actual_location, human: result.guess_location });
|
162 |
+
return;
|
163 |
+
}
|
164 |
+
chat.textContent += (aiData.text || '') + '\n';
|
165 |
+
openResultsPopup({ actual: result.actual_location, human: result.guess_location });
|
166 |
+
if (aiData.ai_guess) renderAIGuessOnPopup(aiData.ai_guess);
|
167 |
+
document.getElementById('next-round-btn').disabled = false;
|
168 |
+
}
|
169 |
+
|
170 |
+
function openResultsPopup(points) {
|
171 |
+
const overlay = document.getElementById('popup-overlay');
|
172 |
+
overlay.style.display = 'flex';
|
173 |
+
const popupMap = new google.maps.Map(document.getElementById('popup-map'), { zoom: 3, center: points.actual });
|
174 |
+
new google.maps.Marker({ position: points.actual, map: popupMap, label: 'A' });
|
175 |
+
new google.maps.Marker({ position: points.human, map: popupMap, label: 'H' });
|
176 |
+
new google.maps.Polyline({ path: [points.actual, points.human], geodesic: true, strokeColor: '#F97316', strokeOpacity: 1.0, strokeWeight: 2, map: popupMap });
|
177 |
+
document.getElementById('next-round-btn').disabled = true;
|
178 |
+
}
|
179 |
+
|
180 |
+
function appendChatToken(t) {
|
181 |
+
const el = document.getElementById('chat-log');
|
182 |
+
const span = document.createElement('span');
|
183 |
+
span.textContent = t;
|
184 |
+
el.appendChild(span);
|
185 |
+
el.scrollTop = el.scrollHeight;
|
186 |
+
}
|
187 |
+
|
188 |
+
function renderAIGuessOnPopup(ai) {
|
189 |
+
const mapEl = document.getElementById('popup-map');
|
190 |
+
const popupMap = mapEl._map || new google.maps.Map(mapEl, { zoom: 3, center: ai });
|
191 |
+
mapEl._map = popupMap;
|
192 |
+
new google.maps.Marker({ position: ai, map: popupMap, label: 'AI' });
|
193 |
+
}
|
194 |
+
|
195 |
+
async function nextRound() {
|
196 |
+
if (aiEventSource) { try { aiEventSource.close(); } catch (e) {} }
|
197 |
+
currentRoundIdx += 1;
|
198 |
+
if (currentRoundIdx >= currentRounds.length) {
|
199 |
+
await showFinalResults();
|
200 |
+
return;
|
201 |
+
}
|
202 |
+
document.getElementById('popup-overlay').style.display = 'none';
|
203 |
+
showRound();
|
204 |
+
}
|
205 |
+
|
206 |
+
async function showFinalResults() {
|
207 |
+
const res = await fetch('/api/session_summary');
|
208 |
+
const data = await res.json();
|
209 |
+
document.getElementById('popup-overlay').style.display = 'none';
|
210 |
+
document.getElementById('game-container').style.display = 'none';
|
211 |
+
const fin = document.getElementById('final-results');
|
212 |
+
fin.style.display = 'block';
|
213 |
+
const total = data.total_score?.toFixed ? data.total_score.toFixed(0) : data.total_score;
|
214 |
+
document.getElementById('final-summary').textContent = `Your total score: ${total} / 15000`;
|
215 |
+
}
|
216 |
+
|
217 |
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
static/style.css
ADDED
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
:root {
|
2 |
+
--primary-color: #F97316; /* Orange */
|
3 |
+
--secondary-color: #EA580C; /* Darker Orange */
|
4 |
+
--accent-color: #FB923C; /* Lighter Orange */
|
5 |
+
--dark-color: #202124;
|
6 |
+
--light-color: #FFFBEB; /* Creamy White */
|
7 |
+
--border-radius: 8px;
|
8 |
+
}
|
9 |
+
|
10 |
+
body {
|
11 |
+
font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
12 |
+
margin: 0;
|
13 |
+
padding: 0;
|
14 |
+
background-color: var(--light-color);
|
15 |
+
color: var(--dark-color);
|
16 |
+
}
|
17 |
+
|
18 |
+
h1 {
|
19 |
+
text-align: center;
|
20 |
+
margin: 20px 0;
|
21 |
+
color: var(--primary-color);
|
22 |
+
font-size: 2.5rem;
|
23 |
+
text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
|
24 |
+
}
|
25 |
+
|
26 |
+
#game-container {
|
27 |
+
display: flex;
|
28 |
+
justify-content: space-around;
|
29 |
+
margin: 20px;
|
30 |
+
height: 80vh;
|
31 |
+
gap: 20px;
|
32 |
+
display: none; /* Hidden by default */
|
33 |
+
}
|
34 |
+
|
35 |
+
#streetview-container {
|
36 |
+
width: 50%;
|
37 |
+
height: 100%;
|
38 |
+
border-radius: var(--border-radius);
|
39 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
40 |
+
}
|
41 |
+
|
42 |
+
#map-container {
|
43 |
+
width: 30%;
|
44 |
+
height: 100%;
|
45 |
+
border-radius: var(--border-radius);
|
46 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
47 |
+
}
|
48 |
+
|
49 |
+
#chat-container {
|
50 |
+
width: 20%;
|
51 |
+
height: 100%;
|
52 |
+
display: flex;
|
53 |
+
flex-direction: column;
|
54 |
+
border-radius: var(--border-radius);
|
55 |
+
background-color: white;
|
56 |
+
padding: 15px;
|
57 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
58 |
+
}
|
59 |
+
|
60 |
+
#streetview, #map, #result-map {
|
61 |
+
height: 100%;
|
62 |
+
width: 100%;
|
63 |
+
border-radius: var(--border-radius);
|
64 |
+
}
|
65 |
+
|
66 |
+
#chat-log {
|
67 |
+
flex-grow: 1;
|
68 |
+
overflow-y: auto;
|
69 |
+
margin-bottom: 15px;
|
70 |
+
padding: 10px;
|
71 |
+
background-color: #f8f9fa;
|
72 |
+
border-radius: var(--border-radius);
|
73 |
+
}
|
74 |
+
|
75 |
+
#chat-log div {
|
76 |
+
margin-bottom: 10px;
|
77 |
+
padding: 8px 12px;
|
78 |
+
background-color: white;
|
79 |
+
border-radius: 18px;
|
80 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
81 |
+
}
|
82 |
+
|
83 |
+
#chat-log div strong {
|
84 |
+
color: var(--primary-color);
|
85 |
+
}
|
86 |
+
|
87 |
+
#controls {
|
88 |
+
display: flex;
|
89 |
+
justify-content: center;
|
90 |
+
}
|
91 |
+
|
92 |
+
button {
|
93 |
+
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
94 |
+
color: white;
|
95 |
+
border: none;
|
96 |
+
padding: 10px 20px;
|
97 |
+
border-radius: var(--border-radius);
|
98 |
+
cursor: pointer;
|
99 |
+
font-weight: 500;
|
100 |
+
transition: all 0.2s;
|
101 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
102 |
+
}
|
103 |
+
|
104 |
+
button:hover {
|
105 |
+
background: linear-gradient(135deg, #D9500B, #E56A15); /* Slightly darker gradient on hover */
|
106 |
+
transform: translateY(-1px);
|
107 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
108 |
+
}
|
109 |
+
|
110 |
+
#result-screen {
|
111 |
+
margin: 20px auto;
|
112 |
+
max-width: 800px;
|
113 |
+
text-align: center;
|
114 |
+
background-color: white;
|
115 |
+
padding: 30px;
|
116 |
+
border-radius: var(--border-radius);
|
117 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
118 |
+
}
|
119 |
+
|
120 |
+
#result-summary {
|
121 |
+
margin: 20px 0;
|
122 |
+
font-size: 1.2rem;
|
123 |
+
}
|
124 |
+
|
125 |
+
#result-summary p {
|
126 |
+
margin: 10px 0;
|
127 |
+
}
|
128 |
+
|
129 |
+
.marker-label {
|
130 |
+
color: white;
|
131 |
+
font-weight: bold;
|
132 |
+
text-align: center;
|
133 |
+
padding: 2px 6px;
|
134 |
+
border-radius: 50%;
|
135 |
+
}
|
136 |
+
|
137 |
+
#lobby-container {
|
138 |
+
max-width: 500px;
|
139 |
+
margin: 40px auto;
|
140 |
+
padding: 30px;
|
141 |
+
background-color: white;
|
142 |
+
border-radius: var(--border-radius);
|
143 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
144 |
+
text-align: center;
|
145 |
+
}
|
146 |
+
|
147 |
+
#lobby-container h2 {
|
148 |
+
margin-top: 0;
|
149 |
+
margin-bottom: 20px;
|
150 |
+
}
|
151 |
+
|
152 |
+
#lobby-container hr {
|
153 |
+
margin: 20px 0;
|
154 |
+
border: 0;
|
155 |
+
border-top: 1px solid #eee;
|
156 |
+
}
|
157 |
+
|
158 |
+
#lobby-container input {
|
159 |
+
width: calc(100% - 22px);
|
160 |
+
padding: 10px;
|
161 |
+
margin-bottom: 10px;
|
162 |
+
border: 1px solid #ccc;
|
163 |
+
border-radius: var(--border-radius);
|
164 |
+
}
|
165 |
+
|
166 |
+
/* Hide the address text in Street View */
|
167 |
+
.gm-iv-address {
|
168 |
+
display: none !important;
|
169 |
+
}
|
templates/admin.html
ADDED
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html>
|
3 |
+
<head>
|
4 |
+
<title>Admin Panel - LLM GeoGuessr</title>
|
5 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
6 |
+
<style>
|
7 |
+
h1 {
|
8 |
+
color: var(--dark-color);
|
9 |
+
text-align: center;
|
10 |
+
}
|
11 |
+
#controls {
|
12 |
+
width: 90%;
|
13 |
+
max-width: 800px;
|
14 |
+
margin: 20px auto;
|
15 |
+
padding: 20px;
|
16 |
+
background-color: white;
|
17 |
+
border-radius: var(--border-radius);
|
18 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
19 |
+
display: flex;
|
20 |
+
align-items: center;
|
21 |
+
justify-content: space-around;
|
22 |
+
flex-wrap: wrap;
|
23 |
+
}
|
24 |
+
#controls h2 {
|
25 |
+
display: none; /* Title is obvious from context */
|
26 |
+
}
|
27 |
+
#controls .control-item {
|
28 |
+
margin: 5px 10px;
|
29 |
+
}
|
30 |
+
#controls .control-item p {
|
31 |
+
margin: 0;
|
32 |
+
font-size: 0.9rem;
|
33 |
+
color: #555;
|
34 |
+
}
|
35 |
+
#controls .control-item label {
|
36 |
+
margin-right: 5px;
|
37 |
+
}
|
38 |
+
#controls .control-item select,
|
39 |
+
#controls .control-item button {
|
40 |
+
width: 100%;
|
41 |
+
padding: 10px;
|
42 |
+
box-sizing: border-box; /* Ensures padding is included in the width */
|
43 |
+
}
|
44 |
+
#map {
|
45 |
+
height: 75vh;
|
46 |
+
margin: 0 auto;
|
47 |
+
width: calc(100% - 40px);
|
48 |
+
border-radius: var(--border-radius);
|
49 |
+
}
|
50 |
+
</style>
|
51 |
+
</head>
|
52 |
+
<body>
|
53 |
+
<h1>Admin Panel</h1>
|
54 |
+
<div id="controls">
|
55 |
+
<div class="control-item">
|
56 |
+
<p>Select "Draw" then use the rectangle tool on the map.</p>
|
57 |
+
</div>
|
58 |
+
<div class="control-item">
|
59 |
+
<button id="new-zone-btn">Draw New Zone</button>
|
60 |
+
</div>
|
61 |
+
<div class="control-item">
|
62 |
+
<label for="difficulty-select">Difficulty:</label>
|
63 |
+
<select id="difficulty-select">
|
64 |
+
<option value="easy">Easy</option>
|
65 |
+
<option value="medium">Medium</option>
|
66 |
+
<option value="hard">Hard</option>
|
67 |
+
</select>
|
68 |
+
</div>
|
69 |
+
<div class="control-item">
|
70 |
+
<button id="save-zone" disabled>Save Zone</button>
|
71 |
+
</div>
|
72 |
+
</div>
|
73 |
+
<div id="status-message-container" style="text-align: center; margin-bottom: 10px;">
|
74 |
+
<p id="status-message"></p>
|
75 |
+
</div>
|
76 |
+
<div id="map"></div>
|
77 |
+
<script src="{{ url_for('static', filename='admin.js') }}"></script>
|
78 |
+
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&libraries=drawing&callback=initAdminMap"></script>
|
79 |
+
</body>
|
80 |
+
</html>
|
templates/game.html
ADDED
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html>
|
3 |
+
<head>
|
4 |
+
<title>Play - LLM GeoGuessr</title>
|
5 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
6 |
+
<style>
|
7 |
+
#image-container { position: relative; max-width: 960px; margin: 0 auto; }
|
8 |
+
#street-image { width: 100%; height: auto; border-radius: var(--border-radius); box-shadow: 0 4px 6px rgba(0,0,0,0.1); user-select: none; }
|
9 |
+
#mini-map-wrap { position: absolute; right: 10px; bottom: 10px; border: 2px solid #fff; border-radius: var(--border-radius); box-shadow: 0 2px 8px rgba(0,0,0,0.2); background: #fff; }
|
10 |
+
#mini-map-wrap.size-small { width: 220px; height: 140px; }
|
11 |
+
#mini-map-wrap.size-medium { width: 280px; height: 180px; }
|
12 |
+
#mini-map-wrap.size-large { width: 420px; height: 270px; }
|
13 |
+
#mini-map { width: 100%; height: 100%; cursor: pointer; }
|
14 |
+
#map-controls { position: absolute; right: 8px; top: 8px; display: flex; flex-direction: row; gap: 6px; z-index: 5; }
|
15 |
+
.map-ctrl { width: 34px; height: 28px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); background: #202124; color: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.25); cursor: pointer; font-weight: 600; line-height: 26px; }
|
16 |
+
.map-ctrl:hover { background: #3c4043; }
|
17 |
+
#validate-btn { position: absolute; left: 50%; transform: translateX(-50%); bottom: 10px; display: none; }
|
18 |
+
#chat-container { position: absolute; left: 10px; bottom: 10px; width: 360px; max-height: 45%; background: rgba(255,255,255,0.95); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); padding: 12px; overflow: hidden; z-index: 20; }
|
19 |
+
#chat-log { overflow-y: auto; max-height: calc(45vh - 64px); white-space: pre-wrap; font-size: 0.95rem; line-height: 1.3; }
|
20 |
+
#popup-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
|
21 |
+
#popup-card { background: white; padding: 20px; border-radius: var(--border-radius); width: 90%; max-width: 900px; }
|
22 |
+
#popup-map { width: 100%; height: 420px; border-radius: var(--border-radius); }
|
23 |
+
</style>
|
24 |
+
</head>
|
25 |
+
<body>
|
26 |
+
<h1>Play</h1>
|
27 |
+
<div id="game-container">
|
28 |
+
<div id="image-container">
|
29 |
+
<img id="street-image" alt="Street View" />
|
30 |
+
<div id="mini-map-wrap" class="size-medium" title="Click to place your guess">
|
31 |
+
<div id="mini-map"></div>
|
32 |
+
<div id="map-controls">
|
33 |
+
<button id="map-size-minus" class="map-ctrl">−</button>
|
34 |
+
<button id="map-size-plus" class="map-ctrl">+</button>
|
35 |
+
</div>
|
36 |
+
</div>
|
37 |
+
<button id="validate-btn">Validate Guess</button>
|
38 |
+
</div>
|
39 |
+
<div id="chat-container" style="display:none;">
|
40 |
+
<h3>AI Analysis</h3>
|
41 |
+
<div id="chat-log"></div>
|
42 |
+
</div>
|
43 |
+
</div>
|
44 |
+
|
45 |
+
<div id="popup-overlay">
|
46 |
+
<div id="popup-card">
|
47 |
+
<h3>Round Results</h3>
|
48 |
+
<div id="popup-map"></div>
|
49 |
+
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:12px;">
|
50 |
+
<button id="next-round-btn">Next</button>
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
</div>
|
54 |
+
|
55 |
+
<div id="final-results" style="display:none;" class="muted">
|
56 |
+
<h2>Game Complete</h2>
|
57 |
+
<p id="final-summary"></p>
|
58 |
+
<a href="/"><button>Back to Home</button></a>
|
59 |
+
</div>
|
60 |
+
|
61 |
+
<script src="{{ url_for('static', filename='script.js') }}"> </script>
|
62 |
+
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=initGameApp"></script>
|
63 |
+
</body>
|
64 |
+
</html>
|
65 |
+
|
templates/index.html
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html>
|
3 |
+
<head>
|
4 |
+
<title>LLM GeoGuessr</title>
|
5 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
6 |
+
<style>
|
7 |
+
#auth-card, #gate-card { max-width: 560px; margin: 20px auto; background: white; padding: 24px; border-radius: var(--border-radius); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
8 |
+
.muted { color: #666; font-size: 0.95rem; }
|
9 |
+
</style>
|
10 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
11 |
+
<meta name="referrer" content="no-referrer" />
|
12 |
+
<meta http-equiv="Cross-Origin-Opener-Policy" content="same-origin" />
|
13 |
+
<meta http-equiv="Cross-Origin-Embedder-Policy" content="require-corp" />
|
14 |
+
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
15 |
+
<meta http-equiv="X-Frame-Options" content="DENY" />
|
16 |
+
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin" />
|
17 |
+
<meta http-equiv="Cache-Control" content="no-store" />
|
18 |
+
<meta http-equiv="Pragma" content="no-cache" />
|
19 |
+
<meta http-equiv="Expires" content="0" />
|
20 |
+
<meta name="google-maps-api-key" content="{{ google_maps_api_key }}" />
|
21 |
+
<meta name="description" content="Daily GeoGuessr-style challenge with LLM rival" />
|
22 |
+
<meta name="theme-color" content="#F97316" />
|
23 |
+
<link rel="preconnect" href="https://maps.googleapis.com" crossorigin>
|
24 |
+
<link rel="preconnect" href="https://maps.gstatic.com" crossorigin>
|
25 |
+
<link rel="dns-prefetch" href="https://maps.googleapis.com">
|
26 |
+
<link rel="dns-prefetch" href="https://maps.gstatic.com">
|
27 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🗺️</text></svg>">
|
28 |
+
<meta name="robots" content="noindex, nofollow">
|
29 |
+
</head>
|
30 |
+
<body>
|
31 |
+
<h1>LLM GeoGuessr</h1>
|
32 |
+
|
33 |
+
<div id="auth-card">
|
34 |
+
<div id="auth-section">
|
35 |
+
<p class="muted">Sign in to play today’s single round set.</p>
|
36 |
+
<a id="signin-btn" href="/login"><button>Sign in with Hugging Face</button></a>
|
37 |
+
<div id="signedin" style="display:none;">
|
38 |
+
<p>Signed in as <strong id="username"></strong></p>
|
39 |
+
<a href="/logout"><button>Sign out</button></a>
|
40 |
+
</div>
|
41 |
+
</div>
|
42 |
+
</div>
|
43 |
+
|
44 |
+
<div id="gate-card">
|
45 |
+
<div id="play-gate">
|
46 |
+
<label for="difficulty-select-lobby">Choose a difficulty:</label>
|
47 |
+
<select id="difficulty-select-lobby">
|
48 |
+
<option value="easy">Easy</option>
|
49 |
+
<option value="medium">Medium</option>
|
50 |
+
<option value="hard">Hard</option>
|
51 |
+
</select>
|
52 |
+
<button id="start-btn" disabled>Start Today’s Game</button>
|
53 |
+
<p id="limit-msg" class="muted" style="display:none;"></p>
|
54 |
+
</div>
|
55 |
+
</div>
|
56 |
+
|
57 |
+
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
58 |
+
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=initIndexApp"></script>
|
59 |
+
</body>
|
60 |
+
</html>
|
zones.json
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"easy": [
|
3 |
+
{
|
4 |
+
"type": "rectangle",
|
5 |
+
"bounds": {
|
6 |
+
"south": 48.8336736971006,
|
7 |
+
"west": 2.28515625,
|
8 |
+
"north": 48.87885155432368,
|
9 |
+
"east": 2.39776611328125
|
10 |
+
},
|
11 |
+
"id": "fc849008dae8481092eb79750ffb29b3"
|
12 |
+
},
|
13 |
+
{
|
14 |
+
"type": "rectangle",
|
15 |
+
"bounds": {
|
16 |
+
"south": 35.66847408359237,
|
17 |
+
"west": 139.62718704877395,
|
18 |
+
"north": 35.737615509324385,
|
19 |
+
"east": 139.8218510502388
|
20 |
+
},
|
21 |
+
"id": "898886b44cad43b9abfec296a553daea"
|
22 |
+
},
|
23 |
+
{
|
24 |
+
"type": "rectangle",
|
25 |
+
"bounds": {
|
26 |
+
"south": 37.536938882023136,
|
27 |
+
"west": 126.94755460738277,
|
28 |
+
"north": 37.557898527241505,
|
29 |
+
"east": 127.01244260787105
|
30 |
+
},
|
31 |
+
"id": "ba27424f2f584ed0b238795608503149"
|
32 |
+
},
|
33 |
+
{
|
34 |
+
"type": "rectangle",
|
35 |
+
"bounds": {
|
36 |
+
"south": 18.953448012353313,
|
37 |
+
"west": 72.81336899465802,
|
38 |
+
"north": 18.976825403712557,
|
39 |
+
"east": 72.83911820120099
|
40 |
+
},
|
41 |
+
"id": "71ec558ad7d7476297452801a170f10c"
|
42 |
+
},
|
43 |
+
{
|
44 |
+
"type": "rectangle",
|
45 |
+
"bounds": {
|
46 |
+
"south": 40.713060179679026,
|
47 |
+
"west": -74.00079248380419,
|
48 |
+
"north": 40.76040587151275,
|
49 |
+
"east": -73.97933481168505
|
50 |
+
},
|
51 |
+
"id": "c03e32aeb33a444f8eac20b24d946b34"
|
52 |
+
},
|
53 |
+
{
|
54 |
+
"type": "rectangle",
|
55 |
+
"bounds": {
|
56 |
+
"south": 37.77180843179515,
|
57 |
+
"west": -122.4442846812313,
|
58 |
+
"north": 37.80328200680126,
|
59 |
+
"east": -122.40857911482505
|
60 |
+
},
|
61 |
+
"id": "41ce7507e12a44b8b964d9c1d2755c42"
|
62 |
+
}
|
63 |
+
],
|
64 |
+
"medium": [],
|
65 |
+
"hard": []
|
66 |
+
}
|