Compare commits
5 Commits
5f18aab285
...
07dff9194a
| Author | SHA1 | Date | |
|---|---|---|---|
| 07dff9194a | |||
| daf06564ba | |||
| 296a8f5d51 | |||
| 6306cfc374 | |||
| 0019644e61 |
230
README.md
230
README.md
@ -68,124 +68,174 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
### Experiment Scripts Overview
|
### Experiment Scripts Overview
|
||||||
|
|
||||||
- **app.py**: Web interface for controlling the experiment and word list
|
- **app.py**: Main server - web interface, experiment control, and automatic tracking recorder
|
||||||
- **server.py**: UDP relay server for communication between VR clients
|
- **server.py**: UDP relay for communication between VR clients (auto-started by app.py)
|
||||||
- **control.py**: Command-line tool for setting VR client modes and server IP
|
- **index.html**: Web UI for configuring and running experiments
|
||||||
- **index.html**: Web UI served by app.py
|
- **static/**: Frontend assets (CSS, JavaScript, player display)
|
||||||
- **word-list.txt**: Default list of charade words
|
- **data/**: Word lists (English and German)
|
||||||
|
|
||||||
### Setup Instructions
|
### Setup Instructions
|
||||||
|
|
||||||
#### 1. Network Setup
|
#### 1. Network Setup
|
||||||
- Connect all VR headsets to the same network as the server
|
- Connect all VR headsets to the same network as your computer
|
||||||
- Note the IP addresses of the VR headsets (you'll need these for configuration)
|
- Note the IP addresses of both VR headsets
|
||||||
- **Important**: Update `experiment-scripts/server.py` lines 19-20 with your actual VR headset IPs
|
- Note your computer's IP address (the server IP)
|
||||||
```python
|
|
||||||
# Replace these example IPs with your actual headset IPs
|
|
||||||
DEVICE1_ADDR = ("YOUR_PLAYER1_IP", 5001) # e.g., ("192.168.1.100", 5001)
|
|
||||||
DEVICE2_ADDR = ("YOUR_PLAYER2_IP", 5001) # e.g., ("192.168.1.101", 5001)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Start the Relay Server
|
To find your server IP:
|
||||||
```bash
|
```bash
|
||||||
cd experiment-scripts
|
# Linux/Mac
|
||||||
python server.py
|
hostname -I
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
ipconfig
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Start the Web Interface
|
**Note**: The UDP relay automatically detects and forwards data between connected VR headsets - no manual IP configuration needed in server.py!
|
||||||
|
|
||||||
|
#### 2. Start the Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd experiment-scripts
|
cd experiment-scripts
|
||||||
python -m fastapi dev app.py
|
fastapi dev app.py
|
||||||
```
|
|
||||||
Then navigate to `http://localhost:8000`
|
|
||||||
|
|
||||||
#### 4. Configure VR Clients
|
|
||||||
|
|
||||||
Tell clients which server to connect to:
|
|
||||||
```bash
|
|
||||||
# Windows PowerShell (replace with your actual VR headset IPs)
|
|
||||||
cd experiment-scripts
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "IP:127.0.0.1"
|
|
||||||
|
|
||||||
# Linux/Mac (replace with your actual VR headset IPs)
|
|
||||||
cd experiment-scripts
|
|
||||||
TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" python3 control.py "IP:127.0.0.1"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5. Set Experiment Condition
|
This single command automatically starts:
|
||||||
|
- Web interface on http://localhost:8000
|
||||||
|
- UDP relay server (server.py)
|
||||||
|
- Tracking data recorder
|
||||||
|
|
||||||
Choose one of these modes:
|
Navigate to `http://localhost:8000` to access the control interface.
|
||||||
```bash
|
|
||||||
# Dynamic Face only
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;1;1;0"
|
|
||||||
|
|
||||||
# Dynamic Hands only
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:0;0;0;1"
|
|
||||||
|
|
||||||
# Dynamic Hands + Face
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;1;1;1"
|
|
||||||
|
|
||||||
# Static Face only
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;0;0;0"
|
|
||||||
|
|
||||||
# Static Hands only (requires controllers)
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:0;0;0;1"
|
|
||||||
|
|
||||||
# Static Hands + Face (requires controllers for hands)
|
|
||||||
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;0;0;1"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6. Prepare Word List
|
|
||||||
|
|
||||||
You can shuffle the word list directly in the web interface (see Web Interface Usage section below).
|
|
||||||
|
|
||||||
### Web Interface Usage
|
### Web Interface Usage
|
||||||
|
|
||||||
Once you have the web interface running at `http://localhost:8000`:
|
All experiment configuration and control is done through the web interface at `http://localhost:8000`.
|
||||||
|
|
||||||
#### Setting Up a Charades Session
|
#### 1. Configure VR Headsets
|
||||||
|
|
||||||
1. **Configure Player IPs**
|
In the **VR Headset Configuration** section:
|
||||||
- Enter the IP addresses of Player 1 and Player 2 VR headsets
|
- Enter **Server IP Address**: Your computer's IP address
|
||||||
- These should match the IPs you used in the `control.py` commands
|
- Enter **Player 1 IP Address**: First VR headset IP
|
||||||
|
- Enter **Player 2 IP Address**: Second VR headset IP
|
||||||
|
- Select **Experiment Mode** (see Experiment Conditions below)
|
||||||
|
- Click **"Send Configuration to Headsets"**
|
||||||
|
|
||||||
2. **Prepare Word List**
|
Wait for confirmation that the configuration was sent successfully.
|
||||||
- Copy your word list from `word-list.txt` and paste it into the large text area on the right side
|
|
||||||
- Click the **"Shuffle"** button to randomize the word order
|
|
||||||
- Click the **"Modify"** button to generate interactive word items
|
|
||||||
|
|
||||||
3. **Set Game Parameters**
|
#### 2. Configure Experiment Session
|
||||||
- **Current Player For Acting**: Select which player will be acting out the words
|
|
||||||
- **Time (s)**: Set the time limit for each word (e.g., 30 seconds)
|
|
||||||
- **Last Word Status**: Set to "None" for the first word
|
|
||||||
|
|
||||||
#### Running the Experiment
|
In the **Session Configuration** section:
|
||||||
|
- **Group ID**: Identifier for this session (used in CSV filenames)
|
||||||
|
- **Time per Word**: Duration in seconds for each word (e.g., 30)
|
||||||
|
- **Total Duration**: Total experiment time in minutes (0 = unlimited)
|
||||||
|
|
||||||
**Manual Mode (Individual Words)**
|
In the **Network Configuration** section:
|
||||||
1. Enter a word in the "Word" field
|
- **Active Player**: Select which player will be performing (Player 1 or Player 2)
|
||||||
2. Select the acting player (Player 1 or Player 2)
|
|
||||||
3. Set the time limit
|
|
||||||
4. Click **"Send"** to transmit the word to the VR headsets
|
|
||||||
|
|
||||||
**Automatic Mode (Word List)**
|
#### 3. Prepare Word List
|
||||||
1. After clicking "Modify" with your word list, the interface shows all words with timers
|
|
||||||
2. The system automatically starts with the first word and counts down
|
|
||||||
3. **During the session**:
|
|
||||||
- **Mark words correct**: Hover over a word and check the checkbox if guessed correctly
|
|
||||||
- **Visual indicators**: ▶ = Current word, ✅ = Correct, ❌ = Time expired
|
|
||||||
4. **Stop the session**: Click **"Stop"** to end early
|
|
||||||
|
|
||||||
#### Exporting Results
|
In the **Word List** section:
|
||||||
|
- Copy words from `data/word-list.txt` or enter your own (one word per line)
|
||||||
|
- Click **"Shuffle Words"** to randomize order
|
||||||
|
- Click **"Start Experiment"** when ready
|
||||||
|
|
||||||
1. After completing the word list, click **"Save as CSV"**
|
#### 4. Run the Experiment
|
||||||
2. This downloads a CSV file with word name, correctness, and time remaining
|
|
||||||
|
When you click **"Start Experiment"**:
|
||||||
|
- The system automatically sends words to the active player
|
||||||
|
- Tracking data recording starts automatically
|
||||||
|
- Words advance based on the timer
|
||||||
|
- Check the checkbox next to each word if guessed correctly
|
||||||
|
- Click **"Stop Experiment"** to end early
|
||||||
|
|
||||||
|
#### 5. Export Data
|
||||||
|
|
||||||
|
After the experiment:
|
||||||
|
- **"Save Results (CSV)"**: Downloads word results
|
||||||
|
- Format: `{group_id}_results_{timestamp}.csv`
|
||||||
|
- Contains: word, correct/incorrect, time remaining
|
||||||
|
|
||||||
|
- **"Download Tracking Data (CSV)"**: Downloads tracking data
|
||||||
|
- Format: `{group_id}_tracking_{timestamp}.csv`
|
||||||
|
- Contains: camera and controller positions/rotations at 60Hz
|
||||||
|
- Includes: timestamps, current word, condition, elapsed time
|
||||||
|
|
||||||
|
### Experiment Conditions
|
||||||
|
|
||||||
|
The experiment supports six different conditions that control which body parts are tracked and how:
|
||||||
|
|
||||||
|
| Condition | Description | Settings |
|
||||||
|
|-----------|-------------|----------|
|
||||||
|
| **Dynamic Face** | Real-time face tracking with expressions and eye rotation | 1;1;1;0 |
|
||||||
|
| **Dynamic Hands** | Real-time hand tracking with finger gestures | 0;0;0;1 |
|
||||||
|
| **Dynamic Hands+Face** | Full tracking: face, expressions, eyes, and hands | 1;1;1;1 |
|
||||||
|
| **Static Face** | Head position tracking only (no expressions) | 1;0;0;0 |
|
||||||
|
| **Static Hands** | Controller tracking (no finger tracking) | 0;0;0;1 |
|
||||||
|
| **Static Hands+Face** | Head position + controller tracking | 1;0;0;1 |
|
||||||
|
|
||||||
|
**Mode format**: `<show_head>;<show_facial_expression>;<show_eye_rotation>;<show_hands>`
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- **Dynamic modes**: Use natural face/hand tracking via Quest Pro sensors
|
||||||
|
- **Static modes**: Participants must use controllers for hand input
|
||||||
|
- Select the condition in the web interface before starting the experiment
|
||||||
|
|
||||||
|
### Tracking Data
|
||||||
|
|
||||||
|
The system automatically records tracking data from the **active player** (the one performing charades) at approximately 60Hz:
|
||||||
|
|
||||||
|
**Recorded data**:
|
||||||
|
- Center eye camera position (x, y, z) and rotation (w, x, y, z)
|
||||||
|
- Left hand controller position and rotation
|
||||||
|
- Right hand controller position and rotation
|
||||||
|
- Current word being performed
|
||||||
|
- Timestamps and elapsed time
|
||||||
|
|
||||||
|
**Data recording**:
|
||||||
|
- Starts automatically when experiment starts
|
||||||
|
- Stops automatically when experiment stops
|
||||||
|
- Exports as CSV with group ID and timestamp in filename
|
||||||
|
|
||||||
|
|
||||||
|
### Tracking Data CSV Structure
|
||||||
|
|
||||||
|
The exported tracking data CSV contains the following columns:
|
||||||
|
|
||||||
|
| Column Name | Description | Example Value |
|
||||||
|
|------------------------- |--------------------------------------------------|-----------------------|
|
||||||
|
| timestamp | Unix timestamp (seconds since epoch) | 1718123456.1234 |
|
||||||
|
| elapsed_time | Seconds since experiment start | 12.3456 |
|
||||||
|
| player_id | "player1" or "player2" | player1 |
|
||||||
|
| role | "mimicker" or "guesser" | mimicker |
|
||||||
|
| group_id | Experiment group identifier | g1 |
|
||||||
|
| condition | Experiment mode string | 1;1;1;1 |
|
||||||
|
| current_word | Word being performed | Applaudieren |
|
||||||
|
| word_time_remaining | Seconds left for current word | 18.1234 |
|
||||||
|
| center_eye_pos_x | Center eye camera position X | 0.1234 |
|
||||||
|
| center_eye_pos_y | Center eye camera position Y | 1.2345 |
|
||||||
|
| center_eye_pos_z | Center eye camera position Z | -0.5678 |
|
||||||
|
| center_eye_rot_w | Center eye camera rotation W (quaternion) | 0.9876 |
|
||||||
|
| center_eye_rot_x | Center eye camera rotation X (quaternion) | 0.0123 |
|
||||||
|
| center_eye_rot_y | Center eye camera rotation Y (quaternion) | 0.0456 |
|
||||||
|
| center_eye_rot_z | Center eye camera rotation Z (quaternion) | -0.0789 |
|
||||||
|
| left_hand_pos_x | Left hand position X | 0.2345 |
|
||||||
|
| left_hand_pos_y | Left hand position Y | 1.3456 |
|
||||||
|
| left_hand_pos_z | Left hand position Z | -0.6789 |
|
||||||
|
| left_hand_rot_w | Left hand rotation W (quaternion) | 0.8765 |
|
||||||
|
| left_hand_rot_x | Left hand rotation X (quaternion) | 0.0234 |
|
||||||
|
| left_hand_rot_y | Left hand rotation Y (quaternion) | 0.0567 |
|
||||||
|
| left_hand_rot_z | Left hand rotation Z (quaternion) | -0.0890 |
|
||||||
|
| right_hand_pos_x | Right hand position X | 0.3456 |
|
||||||
|
| right_hand_pos_y | Right hand position Y | 1.4567 |
|
||||||
|
| right_hand_pos_z | Right hand position Z | -0.7890 |
|
||||||
|
| right_hand_rot_w | Right hand rotation W (quaternion) | 0.7654 |
|
||||||
|
| right_hand_rot_x | Right hand rotation X (quaternion) | 0.0345 |
|
||||||
|
| right_hand_rot_y | Right hand rotation Y (quaternion) | 0.0678 |
|
||||||
|
| right_hand_rot_z | Right hand rotation Z (quaternion) | -0.0901 |
|
||||||
|
|
||||||
|
**All values are separated by semicolons (`;`).**
|
||||||
|
|
||||||
### Mode Details
|
|
||||||
|
|
||||||
Mode format: `<show_head>;<show_facial_expression>;<show_eye_rotation>;<show_hands>`
|
|
||||||
|
|
||||||
- **Dynamic modes**: Real-time face/hand tracking
|
|
||||||
- **Static modes**: Participants use controllers instead of natural movement
|
|
||||||
|
|
||||||
## Unity Project Details
|
## Unity Project Details
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,28 @@
|
|||||||
# Run in dev mode using:
|
# Run in dev mode using:
|
||||||
# fastapi dev app.py
|
# fastapi dev app.py
|
||||||
#
|
#
|
||||||
# After starting the server, you can navigate to http://localhost:8000 to see the web interface.
|
# This will automatically start both the web server AND the UDP relay (server.py).
|
||||||
|
# After starting, navigate to http://localhost:8000 to see the web interface.
|
||||||
#
|
#
|
||||||
# Note: This requires the user to have the fastapi CLI tool installed.
|
# Note: This requires the user to have the fastapi CLI tool installed.
|
||||||
# The user should be in the same directory as `app.py` as well as `index.html`.
|
# The user should be in the same directory as `app.py`, `server.py`, and `index.html`.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
clients = set()
|
clients = set()
|
||||||
|
server_process = None # Will hold the server.py subprocess
|
||||||
|
|
||||||
# Broadcast function to notify all SSE clients
|
# Broadcast function to notify all SSE clients
|
||||||
async def notify_clients(message: str):
|
async def notify_clients(message: str):
|
||||||
@ -35,7 +41,7 @@ async def sock_recvfrom(nonblocking_sock, *pos, loop, **kw):
|
|||||||
finally:
|
finally:
|
||||||
loop.remove_reader(nonblocking_sock.fileno())
|
loop.remove_reader(nonblocking_sock.fileno())
|
||||||
|
|
||||||
# Background task: UDP listener
|
# Background task: UDP listener for SSE clients
|
||||||
async def udp_listener():
|
async def udp_listener():
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
@ -48,13 +54,114 @@ async def udp_listener():
|
|||||||
message = data.decode()
|
message = data.decode()
|
||||||
await notify_clients(message)
|
await notify_clients(message)
|
||||||
|
|
||||||
|
# Background task: Tracking data listener
|
||||||
|
async def tracking_listener():
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.bind(("0.0.0.0", 5002)) # Different port for tracking data
|
||||||
|
sock.setblocking(False)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data, addr = await sock_recvfrom(sock, 1024 * 16, loop=loop) # Larger buffer for tracking data
|
||||||
|
|
||||||
|
# Only record if tracking is active
|
||||||
|
if tracking_state["is_recording"]:
|
||||||
|
try:
|
||||||
|
message = data.decode()
|
||||||
|
|
||||||
|
# Extract source IP from tagged data (added by server.py)
|
||||||
|
if message.startswith("SOURCE_IP:"):
|
||||||
|
parts = message.split("|", 1)
|
||||||
|
source_ip = parts[0].replace("SOURCE_IP:", "")
|
||||||
|
actual_data = parts[1] if len(parts) > 1 else ""
|
||||||
|
else:
|
||||||
|
# Fallback if data isn't tagged (shouldn't happen normally)
|
||||||
|
source_ip = addr[0]
|
||||||
|
actual_data = message
|
||||||
|
|
||||||
|
# Record data from both players
|
||||||
|
if source_ip in [tracking_state["player1_ip"], tracking_state["player2_ip"]]:
|
||||||
|
parsed = parse_tracking_data(actual_data)
|
||||||
|
|
||||||
|
if parsed:
|
||||||
|
import time
|
||||||
|
# Calculate time elapsed since experiment start
|
||||||
|
elapsed_time = time.time() - tracking_state["experiment_start_time"]
|
||||||
|
|
||||||
|
# Determine player_ID and role
|
||||||
|
if source_ip == tracking_state["player1_ip"]:
|
||||||
|
player_id = "player1"
|
||||||
|
else:
|
||||||
|
player_id = "player2"
|
||||||
|
|
||||||
|
# Determine role: active_player_ip is the mimicker
|
||||||
|
if source_ip == tracking_state["active_player_ip"]:
|
||||||
|
role = "mimicker"
|
||||||
|
else:
|
||||||
|
role = "guesser"
|
||||||
|
|
||||||
|
# Add sample with metadata
|
||||||
|
sample = {
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"elapsed_time": elapsed_time,
|
||||||
|
"player_id": player_id,
|
||||||
|
"role": role,
|
||||||
|
"current_word": current_word_state["word"],
|
||||||
|
"word_time_remaining": 0.0,
|
||||||
|
"data": parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate word time remaining if word is active
|
||||||
|
if current_word_state["startTime"]:
|
||||||
|
word_elapsed = time.time() - current_word_state["startTime"]
|
||||||
|
sample["word_time_remaining"] = max(0, current_word_state["timeSeconds"] - word_elapsed)
|
||||||
|
|
||||||
|
tracking_state["samples"].append(sample)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing tracking data: {e}")
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
global server_process
|
||||||
|
|
||||||
|
# Start server.py subprocess
|
||||||
|
print("Starting UDP relay server (server.py)...")
|
||||||
|
try:
|
||||||
|
server_process = subprocess.Popen(
|
||||||
|
["python", "server.py"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
)
|
||||||
|
print(f"UDP relay server started with PID {server_process.pid}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to start server.py: {e}")
|
||||||
|
print("You may need to start server.py manually.")
|
||||||
|
|
||||||
|
# Start background tasks
|
||||||
asyncio.create_task(udp_listener())
|
asyncio.create_task(udp_listener())
|
||||||
|
asyncio.create_task(tracking_listener())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
# Cleanup: Stop server.py subprocess
|
||||||
|
if server_process:
|
||||||
|
print("Stopping UDP relay server...")
|
||||||
|
server_process.terminate()
|
||||||
|
try:
|
||||||
|
server_process.wait(timeout=5)
|
||||||
|
print("UDP relay server stopped")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("Forcefully killing UDP relay server...")
|
||||||
|
server_process.kill()
|
||||||
|
server_process.wait()
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Mount static files for serving CSS, JavaScript, and HTML assets
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
# sock.bind(("0.0.0.0", 5002))
|
# sock.bind(("0.0.0.0", 5002))
|
||||||
@ -68,7 +175,7 @@ async def read_index():
|
|||||||
|
|
||||||
@app.get("/display")
|
@app.get("/display")
|
||||||
async def read_display():
|
async def read_display():
|
||||||
return FileResponse('player-display.html')
|
return FileResponse('static/player-display.html')
|
||||||
|
|
||||||
@app.post("/facialexpressions")
|
@app.post("/facialexpressions")
|
||||||
def read_item(weights: list[float]):
|
def read_item(weights: list[float]):
|
||||||
@ -86,6 +193,19 @@ class Word(BaseModel):
|
|||||||
class WordList(BaseModel):
|
class WordList(BaseModel):
|
||||||
words: list[str]
|
words: list[str]
|
||||||
|
|
||||||
|
class VRConfig(BaseModel):
|
||||||
|
player1_ip: str
|
||||||
|
player2_ip: str
|
||||||
|
server_ip: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
class TrackingStart(BaseModel):
|
||||||
|
group_id: str
|
||||||
|
condition: str
|
||||||
|
player1_ip: str
|
||||||
|
player2_ip: str
|
||||||
|
active_player_ip: str # The player who mimics (mimicker)
|
||||||
|
|
||||||
# Global state for current word display
|
# Global state for current word display
|
||||||
current_word_state = {
|
current_word_state = {
|
||||||
"word": "",
|
"word": "",
|
||||||
@ -94,6 +214,99 @@ current_word_state = {
|
|||||||
"startTime": None
|
"startTime": None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Global state for tracking data recording
|
||||||
|
tracking_state = {
|
||||||
|
"is_recording": False,
|
||||||
|
"group_id": "",
|
||||||
|
"condition": "",
|
||||||
|
"player1_ip": "",
|
||||||
|
"player2_ip": "",
|
||||||
|
"active_player_ip": "", # The mimicker
|
||||||
|
"experiment_start_time": None,
|
||||||
|
"samples": [] # List of tracking data samples
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_tracking_data(data_str):
|
||||||
|
"""
|
||||||
|
Parse tracking data from VR headset to extract camera and controller positions/rotations.
|
||||||
|
Returns dict with center_eye, left_hand, right_hand data or None if parsing fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parts = data_str.split(';')
|
||||||
|
|
||||||
|
# The data structure from TrackWeights.cs:
|
||||||
|
# 0-62: Face expressions (63 values)
|
||||||
|
# 63-66: Root orientation (4 values)
|
||||||
|
# 67-69: Root position (3 values)
|
||||||
|
# 70: Root scale (1 value)
|
||||||
|
# 71: Bone rotations length (1 value)
|
||||||
|
bone_rot_length = int(float(parts[71]))
|
||||||
|
# 72 to 72+(bone_rot_length*4)-1: Bone rotations (4 values each)
|
||||||
|
idx = 72 + (bone_rot_length * 4)
|
||||||
|
# idx: IsDataValid
|
||||||
|
idx += 1
|
||||||
|
# idx: IsDataHighConfidence
|
||||||
|
idx += 1
|
||||||
|
# idx: Bone translations length
|
||||||
|
bone_trans_length = int(float(parts[idx]))
|
||||||
|
idx += 1
|
||||||
|
# idx to idx+(bone_trans_length*3)-1: Bone translations (3 values each)
|
||||||
|
idx += bone_trans_length * 3
|
||||||
|
# idx: SkeletonChangedCount
|
||||||
|
idx += 1
|
||||||
|
# idx to idx+3: Left eye rotation (4 values)
|
||||||
|
idx += 4
|
||||||
|
# idx to idx+3: Right eye rotation (4 values)
|
||||||
|
idx += 4
|
||||||
|
|
||||||
|
# Now we're at the data we want!
|
||||||
|
# Center eye camera: position (3) + rotation (4) = 7 values
|
||||||
|
center_eye_pos_x = float(parts[idx])
|
||||||
|
center_eye_pos_y = float(parts[idx + 1])
|
||||||
|
center_eye_pos_z = float(parts[idx + 2])
|
||||||
|
center_eye_rot_w = float(parts[idx + 3])
|
||||||
|
center_eye_rot_x = float(parts[idx + 4])
|
||||||
|
center_eye_rot_y = float(parts[idx + 5])
|
||||||
|
center_eye_rot_z = float(parts[idx + 6])
|
||||||
|
idx += 7
|
||||||
|
|
||||||
|
# Left hand controller: position (3) + rotation (4) = 7 values
|
||||||
|
left_hand_pos_x = float(parts[idx])
|
||||||
|
left_hand_pos_y = float(parts[idx + 1])
|
||||||
|
left_hand_pos_z = float(parts[idx + 2])
|
||||||
|
left_hand_rot_w = float(parts[idx + 3])
|
||||||
|
left_hand_rot_x = float(parts[idx + 4])
|
||||||
|
left_hand_rot_y = float(parts[idx + 5])
|
||||||
|
left_hand_rot_z = float(parts[idx + 6])
|
||||||
|
idx += 7
|
||||||
|
|
||||||
|
# Right hand controller: position (3) + rotation (4) = 7 values
|
||||||
|
right_hand_pos_x = float(parts[idx])
|
||||||
|
right_hand_pos_y = float(parts[idx + 1])
|
||||||
|
right_hand_pos_z = float(parts[idx + 2])
|
||||||
|
right_hand_rot_w = float(parts[idx + 3])
|
||||||
|
right_hand_rot_x = float(parts[idx + 4])
|
||||||
|
right_hand_rot_y = float(parts[idx + 5])
|
||||||
|
right_hand_rot_z = float(parts[idx + 6])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"center_eye": {
|
||||||
|
"pos": {"x": center_eye_pos_x, "y": center_eye_pos_y, "z": center_eye_pos_z},
|
||||||
|
"rot": {"w": center_eye_rot_w, "x": center_eye_rot_x, "y": center_eye_rot_y, "z": center_eye_rot_z}
|
||||||
|
},
|
||||||
|
"left_hand": {
|
||||||
|
"pos": {"x": left_hand_pos_x, "y": left_hand_pos_y, "z": left_hand_pos_z},
|
||||||
|
"rot": {"w": left_hand_rot_w, "x": left_hand_rot_x, "y": left_hand_rot_y, "z": left_hand_rot_z}
|
||||||
|
},
|
||||||
|
"right_hand": {
|
||||||
|
"pos": {"x": right_hand_pos_x, "y": right_hand_pos_y, "z": right_hand_pos_z},
|
||||||
|
"rot": {"w": right_hand_rot_w, "x": right_hand_rot_x, "y": right_hand_rot_y, "z": right_hand_rot_z}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except (IndexError, ValueError) as e:
|
||||||
|
print(f"Error parsing tracking data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@app.post("/word")
|
@app.post("/word")
|
||||||
def read_word(word: Word):
|
def read_word(word: Word):
|
||||||
import time
|
import time
|
||||||
@ -140,6 +353,156 @@ def shuffle_words(word_list: WordList):
|
|||||||
random.shuffle(shuffled)
|
random.shuffle(shuffled)
|
||||||
return { "status": "ok", "shuffled_words": shuffled }
|
return { "status": "ok", "shuffled_words": shuffled }
|
||||||
|
|
||||||
|
@app.post("/send-config")
|
||||||
|
def send_vr_config(config: VRConfig):
|
||||||
|
"""
|
||||||
|
Send IP and MODE configuration to VR headsets.
|
||||||
|
This integrates the functionality from control.py into the web interface.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create UDP socket for sending commands
|
||||||
|
cmd_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
|
||||||
|
# Send IP configuration to both players
|
||||||
|
ip_msg = f"IP:{config.server_ip}".encode('utf-8')
|
||||||
|
cmd_sock.sendto(ip_msg, (config.player1_ip, 5000))
|
||||||
|
cmd_sock.sendto(ip_msg, (config.player2_ip, 5000))
|
||||||
|
|
||||||
|
# Send MODE configuration to both players
|
||||||
|
mode_msg = f"MODE:{config.mode}".encode('utf-8')
|
||||||
|
cmd_sock.sendto(mode_msg, (config.player1_ip, 5000))
|
||||||
|
cmd_sock.sendto(mode_msg, (config.player2_ip, 5000))
|
||||||
|
|
||||||
|
cmd_sock.close()
|
||||||
|
|
||||||
|
print(f"Sent IP config: {ip_msg.decode()}")
|
||||||
|
print(f"Sent MODE config: {mode_msg.decode()}")
|
||||||
|
print(f"To players: {config.player1_ip}, {config.player2_ip}")
|
||||||
|
|
||||||
|
return { "status": "ok" }
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending VR config: {e}")
|
||||||
|
return { "status": "error", "message": str(e) }
|
||||||
|
|
||||||
|
@app.post("/tracking/start")
|
||||||
|
def start_tracking(config: TrackingStart):
|
||||||
|
"""
|
||||||
|
Start recording tracking data from both players.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
tracking_state["is_recording"] = True
|
||||||
|
tracking_state["group_id"] = config.group_id
|
||||||
|
tracking_state["condition"] = config.condition
|
||||||
|
tracking_state["player1_ip"] = config.player1_ip
|
||||||
|
tracking_state["player2_ip"] = config.player2_ip
|
||||||
|
tracking_state["active_player_ip"] = config.active_player_ip
|
||||||
|
tracking_state["experiment_start_time"] = time.time()
|
||||||
|
tracking_state["samples"] = []
|
||||||
|
|
||||||
|
print(f"Started tracking: group={config.group_id}, condition={config.condition}")
|
||||||
|
print(f" Player1: {config.player1_ip}, Player2: {config.player2_ip}")
|
||||||
|
print(f" Mimicker: {config.active_player_ip}")
|
||||||
|
return { "status": "ok", "message": "Tracking started" }
|
||||||
|
|
||||||
|
@app.post("/tracking/stop")
|
||||||
|
def stop_tracking():
|
||||||
|
"""
|
||||||
|
Stop recording tracking data.
|
||||||
|
"""
|
||||||
|
tracking_state["is_recording"] = False
|
||||||
|
sample_count = len(tracking_state["samples"])
|
||||||
|
print(f"Stopped tracking: {sample_count} samples recorded")
|
||||||
|
return { "status": "ok", "message": f"Tracking stopped. {sample_count} samples recorded." }
|
||||||
|
|
||||||
|
@app.get("/tracking/status")
|
||||||
|
def get_tracking_status():
|
||||||
|
"""
|
||||||
|
Get current tracking status.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"is_recording": tracking_state["is_recording"],
|
||||||
|
"sample_count": len(tracking_state["samples"]),
|
||||||
|
"group_id": tracking_state["group_id"],
|
||||||
|
"condition": tracking_state["condition"]
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/tracking/download")
|
||||||
|
def download_tracking_csv():
|
||||||
|
"""
|
||||||
|
Download tracking data as CSV.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
if len(tracking_state["samples"]) == 0:
|
||||||
|
return { "status": "error", "message": "No tracking data available" }
|
||||||
|
|
||||||
|
# Create CSV content
|
||||||
|
output = io.StringIO()
|
||||||
|
|
||||||
|
# Write header
|
||||||
|
header = [
|
||||||
|
"timestamp", "elapsed_time", "player_id", "role", "group_id", "condition", "current_word", "word_time_remaining",
|
||||||
|
"center_eye_pos_x", "center_eye_pos_y", "center_eye_pos_z",
|
||||||
|
"center_eye_rot_w", "center_eye_rot_x", "center_eye_rot_y", "center_eye_rot_z",
|
||||||
|
"left_hand_pos_x", "left_hand_pos_y", "left_hand_pos_z",
|
||||||
|
"left_hand_rot_w", "left_hand_rot_x", "left_hand_rot_y", "left_hand_rot_z",
|
||||||
|
"right_hand_pos_x", "right_hand_pos_y", "right_hand_pos_z",
|
||||||
|
"right_hand_rot_w", "right_hand_rot_x", "right_hand_rot_y", "right_hand_rot_z"
|
||||||
|
]
|
||||||
|
output.write(";".join(header) + "\n")
|
||||||
|
|
||||||
|
# Write data rows
|
||||||
|
for sample in tracking_state["samples"]:
|
||||||
|
data = sample["data"]
|
||||||
|
row = [
|
||||||
|
str(sample["timestamp"]),
|
||||||
|
f"{sample['elapsed_time']:.4f}",
|
||||||
|
sample["player_id"],
|
||||||
|
sample["role"],
|
||||||
|
tracking_state["group_id"],
|
||||||
|
tracking_state["condition"],
|
||||||
|
sample["current_word"],
|
||||||
|
f"{sample['word_time_remaining']:.4f}",
|
||||||
|
f"{data['center_eye']['pos']['x']:.4f}",
|
||||||
|
f"{data['center_eye']['pos']['y']:.4f}",
|
||||||
|
f"{data['center_eye']['pos']['z']:.4f}",
|
||||||
|
f"{data['center_eye']['rot']['w']:.4f}",
|
||||||
|
f"{data['center_eye']['rot']['x']:.4f}",
|
||||||
|
f"{data['center_eye']['rot']['y']:.4f}",
|
||||||
|
f"{data['center_eye']['rot']['z']:.4f}",
|
||||||
|
f"{data['left_hand']['pos']['x']:.4f}",
|
||||||
|
f"{data['left_hand']['pos']['y']:.4f}",
|
||||||
|
f"{data['left_hand']['pos']['z']:.4f}",
|
||||||
|
f"{data['left_hand']['rot']['w']:.4f}",
|
||||||
|
f"{data['left_hand']['rot']['x']:.4f}",
|
||||||
|
f"{data['left_hand']['rot']['y']:.4f}",
|
||||||
|
f"{data['left_hand']['rot']['z']:.4f}",
|
||||||
|
f"{data['right_hand']['pos']['x']:.4f}",
|
||||||
|
f"{data['right_hand']['pos']['y']:.4f}",
|
||||||
|
f"{data['right_hand']['pos']['z']:.4f}",
|
||||||
|
f"{data['right_hand']['rot']['w']:.4f}",
|
||||||
|
f"{data['right_hand']['rot']['x']:.4f}",
|
||||||
|
f"{data['right_hand']['rot']['y']:.4f}",
|
||||||
|
f"{data['right_hand']['rot']['z']:.4f}"
|
||||||
|
]
|
||||||
|
output.write(";".join(row) + "\n")
|
||||||
|
|
||||||
|
csv_content = output.getvalue()
|
||||||
|
output.close()
|
||||||
|
|
||||||
|
# Create filename with timestamp
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
filename = f"{tracking_state['group_id']}_tracking_{timestamp}.csv"
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=csv_content,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# SSE endpoint
|
# SSE endpoint
|
||||||
@app.get("/news")
|
@app.get("/news")
|
||||||
|
|||||||
@ -5,345 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>VR Charades Experiment Control</title>
|
<title>VR Charades Experiment Control</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #2c3e50;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
border-bottom: 3px solid #34495e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #bdc3c7;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-section {
|
|
||||||
background: white;
|
|
||||||
border: 2px solid #34495e;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #7f8c8d;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-display {
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-display.warning {
|
|
||||||
color: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-display.danger {
|
|
||||||
color: #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-display.inactive {
|
|
||||||
color: #bdc3c7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2c3e50;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 2px solid #ecf0f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #34495e;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
border: 1px solid #cbd5e0;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:disabled {
|
|
||||||
background: #f8f9fa;
|
|
||||||
color: #adb5bd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label input {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: #2980b9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: #27ae60;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover:not(:disabled) {
|
|
||||||
background: #229954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
background: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #95a5a6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background: #7f8c8d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-section {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-section .btn-group {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-display {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-list-container {
|
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-item {
|
|
||||||
background: #fafafa;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-item:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-item.active {
|
|
||||||
background: #e3f2fd;
|
|
||||||
border-color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-item.correct {
|
|
||||||
background: #e8f5e9;
|
|
||||||
border-color: #27ae60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-item.incorrect {
|
|
||||||
background: #ffebee;
|
|
||||||
border-color: #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-index {
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: #7f8c8d;
|
|
||||||
min-width: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-text {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-timer {
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #34495e;
|
|
||||||
min-width: 4rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-checkbox {
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: #95a5a6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: #7f8c8d;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.main-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timer-display {
|
|
||||||
font-size: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@ -411,6 +73,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- VR Headset Configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">VR Headset Configuration</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Server IP Address</label>
|
||||||
|
<input type="text" id="server-ip" class="form-input" placeholder="10.42.0.1">
|
||||||
|
<div class="small-text">IP address that VR headsets should connect to</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Experiment Mode</label>
|
||||||
|
<select id="experiment-mode" class="form-input">
|
||||||
|
<option value="1;1;1;0">Dynamic Face</option>
|
||||||
|
<option value="0;0;0;1">Dynamic Hands</option>
|
||||||
|
<option value="1;1;1;1" selected>Dynamic Hands+Face</option>
|
||||||
|
<option value="1;0;0;0">Static Face</option>
|
||||||
|
<option value="0;0;0;1">Static Hands</option>
|
||||||
|
<option value="1;0;0;1">Static Hands+Face</option>
|
||||||
|
</select>
|
||||||
|
<div class="small-text">Controls which body parts are shown and interaction method</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button id="btn-send-config" class="btn btn-warning">Send Configuration to Headsets</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="config-status" class="status-message" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Word List -->
|
<!-- Word List -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="section-title">Word List</h2>
|
<h2 class="section-title">Word List</h2>
|
||||||
@ -431,6 +123,10 @@
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button id="btn-stop" class="btn btn-danger" disabled>Stop Experiment</button>
|
<button id="btn-stop" class="btn btn-danger" disabled>Stop Experiment</button>
|
||||||
<button id="btn-save" class="btn btn-primary" disabled>Save Results (CSV)</button>
|
<button id="btn-save" class="btn btn-primary" disabled>Save Results (CSV)</button>
|
||||||
|
<button id="btn-download-tracking" class="btn btn-primary">Download Tracking Data (CSV)</button>
|
||||||
|
</div>
|
||||||
|
<div class="small-text" style="margin-top: 1rem;">
|
||||||
|
Tracking data is automatically recorded during experiments and includes camera and controller positions/rotations at 60Hz.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -446,319 +142,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/static/script.js"></script>
|
||||||
let runningWordIndex = -1;
|
|
||||||
let runningWordList = [];
|
|
||||||
let experimentStartTime = null;
|
|
||||||
let totalDurationMs = 0;
|
|
||||||
let frameId = undefined;
|
|
||||||
let last = undefined;
|
|
||||||
let newWord;
|
|
||||||
let lastWordStatus = -1;
|
|
||||||
let experimentRunning = false;
|
|
||||||
|
|
||||||
function updateButtonStates() {
|
|
||||||
document.getElementById("btn-stop").disabled = !experimentRunning;
|
|
||||||
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
|
|
||||||
document.getElementById("btn-start").disabled = experimentRunning;
|
|
||||||
document.getElementById("btn-shuffle").disabled = experimentRunning;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWordItems() {
|
|
||||||
const wordList = document.getElementById("word-list");
|
|
||||||
const text = wordList.value;
|
|
||||||
const display = document.getElementById("word-list-display");
|
|
||||||
display.innerHTML = "";
|
|
||||||
runningWordList = [];
|
|
||||||
runningWordIndex = -1;
|
|
||||||
|
|
||||||
const words = text.trim().split('\n').filter(w => w.trim());
|
|
||||||
|
|
||||||
words.forEach((word, index) => {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.classList.add("word-item");
|
|
||||||
div.setAttribute("data-word", word.trim());
|
|
||||||
div.setAttribute("data-index", index);
|
|
||||||
|
|
||||||
const content = document.createElement("div");
|
|
||||||
content.classList.add("word-content");
|
|
||||||
|
|
||||||
const indexSpan = document.createElement("span");
|
|
||||||
indexSpan.classList.add("word-index");
|
|
||||||
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
|
|
||||||
|
|
||||||
const textSpan = document.createElement("span");
|
|
||||||
textSpan.classList.add("word-text");
|
|
||||||
textSpan.textContent = word.trim();
|
|
||||||
|
|
||||||
content.appendChild(indexSpan);
|
|
||||||
content.appendChild(textSpan);
|
|
||||||
|
|
||||||
const timer = document.createElement("div");
|
|
||||||
timer.classList.add("word-timer");
|
|
||||||
const timeSeconds = parseFloat(document.getElementById("time-s").value);
|
|
||||||
timer.textContent = timeSeconds.toFixed(1) + "s";
|
|
||||||
timer.setAttribute("data-remaining", timeSeconds);
|
|
||||||
|
|
||||||
const checkbox = document.createElement("input");
|
|
||||||
checkbox.type = "checkbox";
|
|
||||||
checkbox.classList.add("word-checkbox");
|
|
||||||
checkbox.addEventListener("change", () => {
|
|
||||||
if (checkbox.checked) {
|
|
||||||
div.classList.add("correct");
|
|
||||||
div.classList.remove("incorrect");
|
|
||||||
} else {
|
|
||||||
div.classList.remove("correct");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
div.appendChild(content);
|
|
||||||
div.appendChild(timer);
|
|
||||||
div.appendChild(checkbox);
|
|
||||||
display.appendChild(div);
|
|
||||||
|
|
||||||
runningWordList.push(div);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function step(timestamp) {
|
|
||||||
if (last === undefined) {
|
|
||||||
last = timestamp;
|
|
||||||
experimentStartTime = timestamp;
|
|
||||||
runningWordIndex = 0;
|
|
||||||
newWord = true;
|
|
||||||
lastWordStatus = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = timestamp - last;
|
|
||||||
last = timestamp;
|
|
||||||
|
|
||||||
// Update total timer
|
|
||||||
const timerDisplay = document.getElementById("timer-display");
|
|
||||||
if (totalDurationMs > 0) {
|
|
||||||
const totalElapsed = timestamp - experimentStartTime;
|
|
||||||
const remainingMs = totalDurationMs - totalElapsed;
|
|
||||||
|
|
||||||
const remainingMinutes = Math.floor(remainingMs / 60000);
|
|
||||||
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
|
|
||||||
timerDisplay.textContent =
|
|
||||||
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
||||||
|
|
||||||
timerDisplay.className = "timer-display";
|
|
||||||
if (remainingMs < 60000) {
|
|
||||||
timerDisplay.classList.add("danger");
|
|
||||||
} else if (remainingMs < 120000) {
|
|
||||||
timerDisplay.classList.add("warning");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalElapsed >= totalDurationMs) {
|
|
||||||
stopExperiment();
|
|
||||||
alert("Experiment completed: Total duration reached");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runningWordIndex >= runningWordList.length) {
|
|
||||||
stopExperiment();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentWordDiv = runningWordList[runningWordIndex];
|
|
||||||
const timer = currentWordDiv.querySelector(".word-timer");
|
|
||||||
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
|
|
||||||
|
|
||||||
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
|
|
||||||
|
|
||||||
if (newWord) {
|
|
||||||
newWord = false;
|
|
||||||
const word = currentWordDiv.getAttribute("data-word");
|
|
||||||
sendNewWord(word, lastWordStatus, remainingTime);
|
|
||||||
|
|
||||||
// Remove active class from all
|
|
||||||
runningWordList.forEach(div => div.classList.remove("active"));
|
|
||||||
currentWordDiv.classList.add("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkbox.checked || remainingTime < 0) {
|
|
||||||
if (remainingTime < 0) remainingTime = 0;
|
|
||||||
|
|
||||||
lastWordStatus = checkbox.checked ? 1 : 0;
|
|
||||||
timer.textContent = remainingTime.toFixed(1) + "s";
|
|
||||||
currentWordDiv.classList.remove("active");
|
|
||||||
|
|
||||||
if (checkbox.checked) {
|
|
||||||
currentWordDiv.classList.add("correct");
|
|
||||||
} else {
|
|
||||||
currentWordDiv.classList.add("incorrect");
|
|
||||||
}
|
|
||||||
|
|
||||||
runningWordIndex++;
|
|
||||||
newWord = true;
|
|
||||||
} else {
|
|
||||||
timer.textContent = remainingTime.toFixed(1) + "s";
|
|
||||||
timer.setAttribute("data-remaining", remainingTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runningWordIndex < runningWordList.length) {
|
|
||||||
frameId = requestAnimationFrame(step);
|
|
||||||
} else {
|
|
||||||
stopExperiment();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopExperiment() {
|
|
||||||
if (frameId) {
|
|
||||||
cancelAnimationFrame(frameId);
|
|
||||||
frameId = undefined;
|
|
||||||
}
|
|
||||||
experimentRunning = false;
|
|
||||||
updateButtonStates();
|
|
||||||
console.log("Experiment stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendNewWord(word, lastWordStatus, timeSeconds) {
|
|
||||||
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
|
|
||||||
const ipPlayer1 = document.getElementById("ip-player1").value;
|
|
||||||
const ipPlayer2 = document.getElementById("ip-player2").value;
|
|
||||||
|
|
||||||
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
|
|
||||||
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
|
|
||||||
|
|
||||||
fetch("/word", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
target: target,
|
|
||||||
lastWordStatus: lastWordStatus,
|
|
||||||
timeSeconds: timeSeconds,
|
|
||||||
word: word,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch("/word", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
target: targetOther,
|
|
||||||
lastWordStatus: lastWordStatus,
|
|
||||||
timeSeconds: 0.0,
|
|
||||||
word: "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
document.getElementById("btn-start").addEventListener("click", () => {
|
|
||||||
const groupId = document.getElementById("group-id").value;
|
|
||||||
|
|
||||||
if (!groupId || !groupId.trim()) {
|
|
||||||
alert("Please enter a Group ID before starting.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordList = document.getElementById("word-list").value;
|
|
||||||
if (!wordList.trim()) {
|
|
||||||
alert("Please enter a word list before starting.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
|
|
||||||
totalDurationMs = totalDurationMin * 60 * 1000;
|
|
||||||
|
|
||||||
const timerDisplay = document.getElementById("timer-display");
|
|
||||||
if (totalDurationMs > 0) {
|
|
||||||
const minutes = Math.floor(totalDurationMs / 60000);
|
|
||||||
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
|
|
||||||
timerDisplay.textContent =
|
|
||||||
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
timerDisplay.className = "timer-display";
|
|
||||||
} else {
|
|
||||||
timerDisplay.textContent = "∞";
|
|
||||||
timerDisplay.className = "timer-display";
|
|
||||||
}
|
|
||||||
|
|
||||||
createWordItems();
|
|
||||||
if (frameId) cancelAnimationFrame(frameId);
|
|
||||||
|
|
||||||
last = undefined;
|
|
||||||
newWord = undefined;
|
|
||||||
experimentStartTime = null;
|
|
||||||
experimentRunning = true;
|
|
||||||
updateButtonStates();
|
|
||||||
|
|
||||||
frameId = requestAnimationFrame(step);
|
|
||||||
|
|
||||||
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min`);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
|
|
||||||
|
|
||||||
document.getElementById("btn-save").addEventListener("click", () => {
|
|
||||||
if (runningWordList.length === 0) {
|
|
||||||
alert("No data to save. Please run the experiment first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupId = document.getElementById("group-id").value || "group";
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
||||||
|
|
||||||
let data = "word;correct;time_left\n";
|
|
||||||
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
|
|
||||||
|
|
||||||
for (let i = 0; i < maxIndex; i++) {
|
|
||||||
const div = runningWordList[i];
|
|
||||||
const word = div.getAttribute("data-word");
|
|
||||||
const checkbox = div.querySelector('input[type="checkbox"]');
|
|
||||||
const timer = div.querySelector(".word-timer");
|
|
||||||
const isCorrect = checkbox.checked;
|
|
||||||
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
|
|
||||||
|
|
||||||
data += `${word};${isCorrect};${timeLeft}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const link = document.createElement("a");
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${groupId}_results_${timestamp}.csv`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("btn-shuffle").addEventListener("click", async () => {
|
|
||||||
const wordList = document.getElementById("word-list");
|
|
||||||
const text = wordList.value.trim();
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
alert("Please enter words to shuffle first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const words = text.split('\n').map(word => word.trim()).filter(word => word);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/shuffle", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ words: words }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.status === "ok") {
|
|
||||||
wordList.value = result.shuffled_words.join('\n');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error shuffling words:", error);
|
|
||||||
alert("Failed to shuffle words. Please try again.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize button states
|
|
||||||
updateButtonStates();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,9 @@
|
|||||||
import socket
|
import socket
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
CONTROL_ADDR = ("127.0.0.1", 5001)
|
# Track connected devices dynamically
|
||||||
|
# No manual IP configuration needed!
|
||||||
# TODO: Adjust the following addresses so they match the IP addresses of the
|
connected_devices = {} # {ip: last_seen_timestamp}
|
||||||
# VR headsets.
|
|
||||||
# In our case the IP addresses were:
|
|
||||||
# - for player 1: 10.42.0.38
|
|
||||||
# - for player 2: 10.42.0.72
|
|
||||||
#
|
|
||||||
# The ports are hardcoded to 5001 inside the Unity application, so you
|
|
||||||
# shouldn't change those.
|
|
||||||
#
|
|
||||||
# Note: For this to work the VR headsets must be connected to the same network
|
|
||||||
# as this server.
|
|
||||||
DEVICE1_ADDR = ("10.42.0.38", 5001)
|
|
||||||
DEVICE2_ADDR = ("10.42.0.72", 5001)
|
|
||||||
|
|
||||||
sock_from_A = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock_from_A = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock_from_A.bind(("0.0.0.0", 5000))
|
sock_from_A.bind(("0.0.0.0", 5000))
|
||||||
@ -26,21 +12,36 @@ def forward(source_socket):
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data, addr = source_socket.recvfrom(1024 * 16)
|
data, addr = source_socket.recvfrom(1024 * 16)
|
||||||
target_ip = DEVICE1_ADDR[0] if addr[0] == DEVICE2_ADDR[0] else DEVICE2_ADDR[0]
|
source_ip = addr[0]
|
||||||
label = "A→B" if addr == DEVICE1_ADDR else "B→A"
|
timestamp = datetime.now()
|
||||||
if addr != DEVICE1_ADDR and addr != DEVICE2_ADDR:
|
|
||||||
label = f"unknown {addr}"
|
# Register this device
|
||||||
|
connected_devices[source_ip] = timestamp
|
||||||
|
|
||||||
|
# Get list of other active devices (excluding localhost and source)
|
||||||
|
other_devices = [ip for ip in connected_devices.keys()
|
||||||
|
if ip != source_ip and not ip.startswith("127.")]
|
||||||
|
|
||||||
|
# Forward to all other connected devices
|
||||||
|
for target_ip in other_devices:
|
||||||
sock_from_A.sendto(data, (target_ip, 5000))
|
sock_from_A.sendto(data, (target_ip, 5000))
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
||||||
|
# Also forward to app.py tracking listener on port 5002
|
||||||
|
# Prepend source IP so app.py can identify which player sent the data
|
||||||
|
tagged_data = f"SOURCE_IP:{source_ip}|".encode('utf-8') + data
|
||||||
|
sock_from_A.sendto(tagged_data, ("127.0.0.1", 5002))
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
#if next(counter) % 20 == 0:
|
if len(other_devices) > 0:
|
||||||
# if addr[0] != DEVICE2_ADDR[0]:
|
label = f"{source_ip} → {', '.join(other_devices)}"
|
||||||
# print(f"[{timestamp}] {label}: {data.decode()}")
|
else:
|
||||||
# print('sent to ', (target_ip, 5000))
|
label = f"{source_ip} (no other devices connected)"
|
||||||
|
|
||||||
|
# Uncomment for verbose logging:
|
||||||
|
# print(f"[{timestamp.strftime('%H:%M:%S')}] {label}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Fehler {label}: {e}")
|
print(f"Error in relay: {e}")
|
||||||
|
|
||||||
print("UDP Relay läuft. Strg+C zum Beenden.")
|
print("UDP Relay läuft. Strg+C zum Beenden.")
|
||||||
try:
|
try:
|
||||||
|
|||||||
479
experiment-scripts/static/script.js
Normal file
479
experiment-scripts/static/script.js
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
// Global state variables
|
||||||
|
let runningWordIndex = -1;
|
||||||
|
let runningWordList = [];
|
||||||
|
let experimentStartTime = null;
|
||||||
|
let totalDurationMs = 0;
|
||||||
|
let frameId = undefined;
|
||||||
|
let last = undefined;
|
||||||
|
let newWord;
|
||||||
|
let lastWordStatus = -1;
|
||||||
|
let experimentRunning = false;
|
||||||
|
|
||||||
|
// Update button states based on experiment status
|
||||||
|
function updateButtonStates() {
|
||||||
|
document.getElementById("btn-stop").disabled = !experimentRunning;
|
||||||
|
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
|
||||||
|
document.getElementById("btn-start").disabled = experimentRunning;
|
||||||
|
document.getElementById("btn-shuffle").disabled = experimentRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create word items from word list
|
||||||
|
function createWordItems() {
|
||||||
|
const wordList = document.getElementById("word-list");
|
||||||
|
const text = wordList.value;
|
||||||
|
const display = document.getElementById("word-list-display");
|
||||||
|
display.innerHTML = "";
|
||||||
|
runningWordList = [];
|
||||||
|
runningWordIndex = -1;
|
||||||
|
|
||||||
|
const words = text.trim().split('\n').filter(w => w.trim());
|
||||||
|
|
||||||
|
words.forEach((word, index) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.classList.add("word-item");
|
||||||
|
div.setAttribute("data-word", word.trim());
|
||||||
|
div.setAttribute("data-index", index);
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.classList.add("word-content");
|
||||||
|
|
||||||
|
const indexSpan = document.createElement("span");
|
||||||
|
indexSpan.classList.add("word-index");
|
||||||
|
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
|
||||||
|
|
||||||
|
const textSpan = document.createElement("span");
|
||||||
|
textSpan.classList.add("word-text");
|
||||||
|
textSpan.textContent = word.trim();
|
||||||
|
|
||||||
|
content.appendChild(indexSpan);
|
||||||
|
content.appendChild(textSpan);
|
||||||
|
|
||||||
|
const timer = document.createElement("div");
|
||||||
|
timer.classList.add("word-timer");
|
||||||
|
const timeSeconds = parseFloat(document.getElementById("time-s").value);
|
||||||
|
timer.textContent = timeSeconds.toFixed(1) + "s";
|
||||||
|
timer.setAttribute("data-remaining", timeSeconds);
|
||||||
|
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.classList.add("word-checkbox");
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
div.classList.add("correct");
|
||||||
|
div.classList.remove("incorrect");
|
||||||
|
} else {
|
||||||
|
div.classList.remove("correct");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
div.appendChild(content);
|
||||||
|
div.appendChild(timer);
|
||||||
|
div.appendChild(checkbox);
|
||||||
|
display.appendChild(div);
|
||||||
|
|
||||||
|
runningWordList.push(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main animation loop
|
||||||
|
function step(timestamp) {
|
||||||
|
if (last === undefined) {
|
||||||
|
last = timestamp;
|
||||||
|
experimentStartTime = timestamp;
|
||||||
|
runningWordIndex = 0;
|
||||||
|
newWord = true;
|
||||||
|
lastWordStatus = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = timestamp - last;
|
||||||
|
last = timestamp;
|
||||||
|
|
||||||
|
// Update total timer
|
||||||
|
const timerDisplay = document.getElementById("timer-display");
|
||||||
|
if (totalDurationMs > 0) {
|
||||||
|
const totalElapsed = timestamp - experimentStartTime;
|
||||||
|
const remainingMs = totalDurationMs - totalElapsed;
|
||||||
|
|
||||||
|
const remainingMinutes = Math.floor(remainingMs / 60000);
|
||||||
|
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
|
||||||
|
timerDisplay.textContent =
|
||||||
|
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
timerDisplay.className = "timer-display";
|
||||||
|
if (remainingMs < 60000) {
|
||||||
|
timerDisplay.classList.add("danger");
|
||||||
|
} else if (remainingMs < 120000) {
|
||||||
|
timerDisplay.classList.add("warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalElapsed >= totalDurationMs) {
|
||||||
|
stopExperiment();
|
||||||
|
alert("Experiment completed: Total duration reached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningWordIndex >= runningWordList.length) {
|
||||||
|
stopExperiment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWordDiv = runningWordList[runningWordIndex];
|
||||||
|
const timer = currentWordDiv.querySelector(".word-timer");
|
||||||
|
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
|
||||||
|
|
||||||
|
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
|
||||||
|
|
||||||
|
if (newWord) {
|
||||||
|
newWord = false;
|
||||||
|
const word = currentWordDiv.getAttribute("data-word");
|
||||||
|
sendNewWord(word, lastWordStatus, remainingTime);
|
||||||
|
|
||||||
|
// Remove active class from all
|
||||||
|
runningWordList.forEach(div => div.classList.remove("active"));
|
||||||
|
currentWordDiv.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkbox.checked || remainingTime < 0) {
|
||||||
|
if (remainingTime < 0) remainingTime = 0;
|
||||||
|
|
||||||
|
lastWordStatus = checkbox.checked ? 1 : 0;
|
||||||
|
timer.textContent = remainingTime.toFixed(1) + "s";
|
||||||
|
currentWordDiv.classList.remove("active");
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
currentWordDiv.classList.add("correct");
|
||||||
|
} else {
|
||||||
|
currentWordDiv.classList.add("incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
runningWordIndex++;
|
||||||
|
newWord = true;
|
||||||
|
} else {
|
||||||
|
timer.textContent = remainingTime.toFixed(1) + "s";
|
||||||
|
timer.setAttribute("data-remaining", remainingTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningWordIndex < runningWordList.length) {
|
||||||
|
frameId = requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
stopExperiment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the experiment
|
||||||
|
function stopExperiment() {
|
||||||
|
if (frameId) {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
frameId = undefined;
|
||||||
|
}
|
||||||
|
experimentRunning = false;
|
||||||
|
updateButtonStates();
|
||||||
|
|
||||||
|
// Stop tracking recording
|
||||||
|
stopTracking();
|
||||||
|
|
||||||
|
console.log("Experiment stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start tracking recording
|
||||||
|
async function startTracking(groupId, condition, activePlayerIp) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/tracking/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
group_id: groupId,
|
||||||
|
condition: condition,
|
||||||
|
active_player_ip: activePlayerIp
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Tracking started:", result.message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting tracking:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop tracking recording
|
||||||
|
async function stopTracking() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/tracking/stop", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Tracking stopped:", result.message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error stopping tracking:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download tracking data
|
||||||
|
async function downloadTracking() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/tracking/download");
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Get filename from Content-Disposition header
|
||||||
|
const disposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = 'tracking_data.csv';
|
||||||
|
if (disposition && disposition.includes('filename=')) {
|
||||||
|
filename = disposition.split('filename=')[1].replace(/"/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
console.log("Tracking data downloaded");
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert("Error: " + (result.message || "Failed to download tracking data"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error downloading tracking:", error);
|
||||||
|
alert("Failed to download tracking data: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send word to VR headsets
|
||||||
|
function sendNewWord(word, lastWordStatus, timeSeconds) {
|
||||||
|
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
|
||||||
|
const ipPlayer1 = document.getElementById("ip-player1").value;
|
||||||
|
const ipPlayer2 = document.getElementById("ip-player2").value;
|
||||||
|
|
||||||
|
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
|
||||||
|
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
|
||||||
|
|
||||||
|
fetch("/word", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
target: target,
|
||||||
|
lastWordStatus: lastWordStatus,
|
||||||
|
timeSeconds: timeSeconds,
|
||||||
|
word: word,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch("/word", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
target: targetOther,
|
||||||
|
lastWordStatus: lastWordStatus,
|
||||||
|
timeSeconds: 0.0,
|
||||||
|
word: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send VR headset configuration (IP and MODE)
|
||||||
|
async function sendVRConfig() {
|
||||||
|
const ipPlayer1 = document.getElementById("ip-player1").value;
|
||||||
|
const ipPlayer2 = document.getElementById("ip-player2").value;
|
||||||
|
const mode = document.getElementById("experiment-mode").value;
|
||||||
|
const serverIp = document.getElementById("server-ip").value;
|
||||||
|
|
||||||
|
if (!ipPlayer1 || !ipPlayer2) {
|
||||||
|
showConfigStatus("Please enter both player IP addresses", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverIp) {
|
||||||
|
showConfigStatus("Please enter the server IP address", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/send-config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
player1_ip: ipPlayer1,
|
||||||
|
player2_ip: ipPlayer2,
|
||||||
|
server_ip: serverIp,
|
||||||
|
mode: mode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status === "ok") {
|
||||||
|
showConfigStatus(`Configuration sent successfully! Mode: ${getModeDescription(mode)}`, "success");
|
||||||
|
} else {
|
||||||
|
showConfigStatus("Failed to send configuration", "error");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending configuration:", error);
|
||||||
|
showConfigStatus("Error sending configuration: " + error.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get human-readable mode description
|
||||||
|
function getModeDescription(mode) {
|
||||||
|
const modes = {
|
||||||
|
"1;1;1;0": "Dynamic Face",
|
||||||
|
"0;0;0;1": "Dynamic Hands",
|
||||||
|
"1;1;1;1": "Dynamic Hands+Face",
|
||||||
|
"1;0;0;0": "Static Face",
|
||||||
|
"0;0;0;1": "Static Hands",
|
||||||
|
"1;0;0;1": "Static Hands+Face"
|
||||||
|
};
|
||||||
|
return modes[mode] || mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show configuration status message
|
||||||
|
function showConfigStatus(message, type) {
|
||||||
|
const statusDiv = document.getElementById("config-status");
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
statusDiv.className = `status-message ${type}`;
|
||||||
|
statusDiv.style.display = "block";
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.style.display = "none";
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// Start Experiment
|
||||||
|
document.getElementById("btn-start").addEventListener("click", () => {
|
||||||
|
const groupId = document.getElementById("group-id").value;
|
||||||
|
|
||||||
|
if (!groupId || !groupId.trim()) {
|
||||||
|
alert("Please enter a Group ID before starting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordList = document.getElementById("word-list").value;
|
||||||
|
if (!wordList.trim()) {
|
||||||
|
alert("Please enter a word list before starting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
|
||||||
|
totalDurationMs = totalDurationMin * 60 * 1000;
|
||||||
|
|
||||||
|
const timerDisplay = document.getElementById("timer-display");
|
||||||
|
if (totalDurationMs > 0) {
|
||||||
|
const minutes = Math.floor(totalDurationMs / 60000);
|
||||||
|
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
|
||||||
|
timerDisplay.textContent =
|
||||||
|
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
timerDisplay.className = "timer-display";
|
||||||
|
} else {
|
||||||
|
timerDisplay.textContent = "∞";
|
||||||
|
timerDisplay.className = "timer-display";
|
||||||
|
}
|
||||||
|
|
||||||
|
createWordItems();
|
||||||
|
if (frameId) cancelAnimationFrame(frameId);
|
||||||
|
|
||||||
|
last = undefined;
|
||||||
|
newWord = undefined;
|
||||||
|
experimentStartTime = null;
|
||||||
|
experimentRunning = true;
|
||||||
|
updateButtonStates();
|
||||||
|
|
||||||
|
// Start tracking recording
|
||||||
|
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
|
||||||
|
const ipPlayer1 = document.getElementById("ip-player1").value;
|
||||||
|
const ipPlayer2 = document.getElementById("ip-player2").value;
|
||||||
|
const activePlayerIp = isPlayer1 ? ipPlayer1 : ipPlayer2;
|
||||||
|
const condition = document.getElementById("experiment-mode").value;
|
||||||
|
const conditionName = getModeDescription(condition);
|
||||||
|
|
||||||
|
startTracking(groupId, conditionName, activePlayerIp);
|
||||||
|
|
||||||
|
frameId = requestAnimationFrame(step);
|
||||||
|
|
||||||
|
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min, Condition=${conditionName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop Experiment
|
||||||
|
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
|
||||||
|
|
||||||
|
// Save Results
|
||||||
|
document.getElementById("btn-save").addEventListener("click", () => {
|
||||||
|
if (runningWordList.length === 0) {
|
||||||
|
alert("No data to save. Please run the experiment first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = document.getElementById("group-id").value || "group";
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||||
|
|
||||||
|
let data = "word;correct;time_left\n";
|
||||||
|
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIndex; i++) {
|
||||||
|
const div = runningWordList[i];
|
||||||
|
const word = div.getAttribute("data-word");
|
||||||
|
const checkbox = div.querySelector('input[type="checkbox"]');
|
||||||
|
const timer = div.querySelector(".word-timer");
|
||||||
|
const isCorrect = checkbox.checked;
|
||||||
|
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
|
||||||
|
|
||||||
|
data += `${word};${isCorrect};${timeLeft}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${groupId}_results_${timestamp}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shuffle Words
|
||||||
|
document.getElementById("btn-shuffle").addEventListener("click", async () => {
|
||||||
|
const wordList = document.getElementById("word-list");
|
||||||
|
const text = wordList.value.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
alert("Please enter words to shuffle first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = text.split('\n').map(word => word.trim()).filter(word => word);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/shuffle", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ words: words }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status === "ok") {
|
||||||
|
wordList.value = result.shuffled_words.join('\n');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error shuffling words:", error);
|
||||||
|
alert("Failed to shuffle words. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send VR Configuration
|
||||||
|
document.getElementById("btn-send-config").addEventListener("click", sendVRConfig);
|
||||||
|
|
||||||
|
// Download Tracking Data
|
||||||
|
document.getElementById("btn-download-tracking").addEventListener("click", downloadTracking);
|
||||||
|
|
||||||
|
// Initialize button states
|
||||||
|
updateButtonStates();
|
||||||
|
});
|
||||||
|
|
||||||
365
experiment-scripts/static/styles.css
Normal file
365
experiment-scripts/static/styles.css
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #2c3e50;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-bottom: 3px solid #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #bdc3c7;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-section {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #34495e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display.warning {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display.danger {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display.inactive {
|
||||||
|
color: #bdc3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 2px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #34495e;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid #cbd5e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover:not(:disabled) {
|
||||||
|
background: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #f39c12;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover:not(:disabled) {
|
||||||
|
background: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section .btn-group {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-display {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-list-container {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-item {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-item:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-item.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-item.correct {
|
||||||
|
background: #e8f5e9;
|
||||||
|
border-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-item.incorrect {
|
||||||
|
background: #ffebee;
|
||||||
|
border-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-index {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-text {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-timer {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #34495e;
|
||||||
|
min-width: 4rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-checkbox {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-display {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user