small refactoring
This commit is contained in:
324
experiment-scripts/static/player-display.html
Normal file
324
experiment-scripts/static/player-display.html
Normal file
@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Charades Player Display</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">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #2c3e50;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: white;
|
||||
border: 2px solid #2c3e50;
|
||||
border-radius: 4px;
|
||||
padding: 1rem 2rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 1rem;
|
||||
color: #7f8c8d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.connected {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.word-display {
|
||||
background: white;
|
||||
border: 3px solid #2c3e50;
|
||||
border-radius: 4px;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-word {
|
||||
font-size: 5rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
line-height: 1.2;
|
||||
word-wrap: break-word;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 4rem;
|
||||
color: #34495e;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.timer.warning {
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
.timer.danger {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.waiting-message {
|
||||
font-size: 1.5rem;
|
||||
color: #95a5a6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.last-word-status {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-correct {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: 2px solid #229954;
|
||||
}
|
||||
|
||||
.status-incorrect {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: 2px solid #c0392b;
|
||||
}
|
||||
|
||||
.status-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.current-word {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.waiting-message {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.word-display {
|
||||
padding: 3rem 1.5rem;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="connection-status connected" id="connection-status">
|
||||
Connected
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="status-bar">
|
||||
<div id="player-info" class="status-text">Waiting for game to start...</div>
|
||||
</div>
|
||||
|
||||
<div class="word-display">
|
||||
<div id="current-word" class="current-word" style="display: none;"></div>
|
||||
<div id="waiting-message" class="waiting-message">
|
||||
Waiting for the next word...
|
||||
</div>
|
||||
<div id="timer" class="timer" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="last-word-status" class="last-word-status status-hidden"></div>
|
||||
|
||||
<script>
|
||||
// Elements
|
||||
const currentWordEl = document.getElementById('current-word');
|
||||
const waitingMessageEl = document.getElementById('waiting-message');
|
||||
const timerEl = document.getElementById('timer');
|
||||
const lastWordStatusEl = document.getElementById('last-word-status');
|
||||
const playerInfoEl = document.getElementById('player-info');
|
||||
const connectionStatusEl = document.getElementById('connection-status');
|
||||
|
||||
let lastWordStatus = -1;
|
||||
let pollInterval = null;
|
||||
|
||||
async function fetchCurrentWord() {
|
||||
try {
|
||||
const response = await fetch('/current-word');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
updateDisplay(data);
|
||||
|
||||
// Update connection status
|
||||
connectionStatusEl.textContent = 'Connected';
|
||||
connectionStatusEl.className = 'connection-status connected';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching current word:', error);
|
||||
connectionStatusEl.textContent = 'Connection Error';
|
||||
connectionStatusEl.className = 'connection-status disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
function updateDisplay(data) {
|
||||
// Show last word status only when it changes and is not -1 (no previous word)
|
||||
if (data.lastWordStatus !== lastWordStatus && data.lastWordStatus !== -1) {
|
||||
showLastWordStatus(data.lastWordStatus);
|
||||
lastWordStatus = data.lastWordStatus;
|
||||
}
|
||||
|
||||
if (data.isActive && data.word && data.word.trim() !== '') {
|
||||
// Show active word with timer
|
||||
currentWordEl.textContent = data.word;
|
||||
currentWordEl.style.display = 'block';
|
||||
waitingMessageEl.style.display = 'none';
|
||||
timerEl.style.display = 'block';
|
||||
|
||||
updateTimer(data.timeRemaining);
|
||||
playerInfoEl.textContent = `Current Word - Time Remaining: ${data.timeRemaining.toFixed(1)}s`;
|
||||
|
||||
} else if (!data.isActive && data.word && data.word.trim() !== '') {
|
||||
// Show word without timer (time is up or no time set)
|
||||
currentWordEl.textContent = data.word;
|
||||
currentWordEl.style.display = 'block';
|
||||
waitingMessageEl.style.display = 'none';
|
||||
timerEl.style.display = 'none';
|
||||
|
||||
playerInfoEl.textContent = 'Current Word - No Time Limit';
|
||||
|
||||
} else {
|
||||
// No word, show waiting message
|
||||
currentWordEl.style.display = 'none';
|
||||
waitingMessageEl.style.display = 'block';
|
||||
timerEl.style.display = 'none';
|
||||
|
||||
playerInfoEl.textContent = 'Waiting for next word...';
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimer(timeRemaining) {
|
||||
const seconds = Math.max(0, timeRemaining);
|
||||
timerEl.textContent = seconds.toFixed(1) + 's';
|
||||
|
||||
// Update timer color based on remaining time
|
||||
timerEl.className = 'timer';
|
||||
if (seconds <= 5) {
|
||||
timerEl.classList.add('danger');
|
||||
} else if (seconds <= 10) {
|
||||
timerEl.classList.add('warning');
|
||||
}
|
||||
}
|
||||
|
||||
function showLastWordStatus(status) {
|
||||
if (status === 1) {
|
||||
lastWordStatusEl.textContent = 'Previous word: CORRECT';
|
||||
lastWordStatusEl.className = 'last-word-status status-correct';
|
||||
setTimeout(() => {
|
||||
lastWordStatusEl.className = 'last-word-status status-hidden';
|
||||
}, 3000);
|
||||
} else if (status === 0) {
|
||||
lastWordStatusEl.textContent = 'Previous word: Time Up';
|
||||
lastWordStatusEl.className = 'last-word-status status-incorrect';
|
||||
setTimeout(() => {
|
||||
lastWordStatusEl.className = 'last-word-status status-hidden';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
// Initial fetch
|
||||
fetchCurrentWord();
|
||||
|
||||
// Poll every 200ms for smooth timer updates
|
||||
pollInterval = setInterval(fetchCurrentWord, 200);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling when page loads
|
||||
window.addEventListener('load', () => {
|
||||
startPolling();
|
||||
});
|
||||
|
||||
// Stop polling when page is about to unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
// Handle page visibility changes (pause polling when tab is hidden)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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