First Iteration of Map Question Type

This commit is contained in:
juhnsa
2025-07-15 23:26:18 +02:00
committed by ben8
parent bb36b3e71f
commit e816c3f75a
10 changed files with 660 additions and 65 deletions

View File

@@ -861,12 +861,12 @@ def new_question(request):
messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.') messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.')
return redirect('library:overview_quiz') return redirect('library:overview_quiz')
if question_type not in ['multiple_choice', 'true_false','input','order','guess']: if question_type not in ['multiple_choice', 'true_false','input','order','guess','map']:
base_url = reverse('library:new_question') base_url = reverse('library:new_question')
return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}') return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}')
if request.method == 'POST': if request.method == 'POST':
question_text = request.POST.get('question', '') question_text = request.POST.get('question', 'Kein Fragentext angegeben')
# Handle different question types # Handle different question types
if question_type == 'true_false': if question_type == 'true_false':
@@ -946,20 +946,27 @@ def new_question(request):
max_value = request.POST.get('max') max_value = request.POST.get('max')
tolerance = request.POST.get('tolerance') tolerance = request.POST.get('tolerance')
json_data = { json_data = {
'type': question_type, 'type': question_type,
'question': question_text, 'question': question_text,
'correct_answer': correct_answer, 'correct_answer': correct_answer,
'min': min_value, 'min': min_value,
'max': max_value, 'max': max_value,
'tolerance': tolerance, 'tolerance': tolerance,
'options': [ 'options': [
{'value': correct_answer, 'is_correct': True} {'value': correct_answer, 'is_correct': True}
] ]
} }
elif question_type == 'map':
target_location = request.POST.get('location')
tolerance_radius = request.POST.get('tolerance_radius')
json_data = {
'type': question_type,
'question': question_text,
'target_location': target_location,
'tolerance_radius': tolerance_radius
}
question = QivipQuestion() question = QivipQuestion()
question.data = json.dumps(json_data) question.data = json.dumps(json_data)
question.quiz_id = quiz question.quiz_id = quiz
@@ -1030,18 +1037,26 @@ def new_question(request):
max_value = request.POST.get('max') max_value = request.POST.get('max')
tolerance = request.POST.get('tolerance') tolerance = request.POST.get('tolerance')
json_data = { json_data = {
'type': question_type, 'type': question_type,
'question': '', 'question': '',
'correct_answer': correct_answer, 'correct_answer': correct_answer,
'min': min_value, 'min': min_value,
'max': max_value, 'max': max_value,
'tolerance': tolerance, 'tolerance': tolerance,
'options': [ 'options': [
{'value': correct_answer, 'is_correct': True} {'value': correct_answer, 'is_correct': True}
] ]
} }
standard_time_per_question = 30 standard_time_per_question = 30
elif question_type == 'map':
question_data = {
'type': question_type,
'question': '',
'target_location': "",
'tolerance_radius': 90000
}
standard_time_per_question = 30
template_name = f'library/question/question_{question_type}.html' template_name = f'library/question/question_{question_type}.html'
context = { context = {
@@ -1083,6 +1098,13 @@ def edit_question(request, pk):
{'value': 'Falsch', 'is_correct': not correct_answer} {'value': 'Falsch', 'is_correct': not correct_answer}
] ]
} }
elif question_type == 'map':
question_data = {
'type': question_type,
'question': question_data.get('question', ''),
'target_location': question_data.get('target_location', ''),
'tolerance_radius': question_data.get('tolerance_radius', 90000)
}
else: else:
# For multiple choice questions # For multiple choice questions
question_data.setdefault('type', question_type) question_data.setdefault('type', question_type)
@@ -1200,17 +1222,27 @@ def edit_question(request, pk):
max_value = request.POST.get('max') max_value = request.POST.get('max')
tolerance = request.POST.get('tolerance') tolerance = request.POST.get('tolerance')
json_data = { json_data = {
'type': question_type, 'type': question_type,
'question': question_text, 'question': question_text,
'correct_answer': correct_answer, 'correct_answer': correct_answer,
'min': min_value, 'min': min_value,
'max': max_value, 'max': max_value,
'tolerance': tolerance, 'tolerance': tolerance,
'options': [ 'options': [
{'value': correct_answer, 'is_correct': True} {'value': correct_answer, 'is_correct': True}
] ]
} }
elif question_type == 'map':
target_location = request.POST.get('location')
tolerance_radius = request.POST.get('tolerance_radius')
json_data = {
'type': question_type,
'question': question_text,
'target_location': target_location,
'tolerance_radius': tolerance_radius
}
question.data = json.dumps(json_data) question.data = json.dumps(json_data)
question.time_per_question = request.POST.get('time_per_question', '30') question.time_per_question = request.POST.get('time_per_question', '30')
try: try:

View File

@@ -6,6 +6,7 @@ from django.utils import timezone
from django.urls import reverse from django.urls import reverse
from play.models import QuizGame, QuizGameParticipant, QuizAnswer from play.models import QuizGame, QuizGameParticipant, QuizAnswer
from django.conf import settings from django.conf import settings
from geopy.distance import geodesic
class GameConsumer(AsyncWebsocketConsumer): class GameConsumer(AsyncWebsocketConsumer):
@@ -31,6 +32,13 @@ class GameConsumer(AsyncWebsocketConsumer):
# Check if game should be deleted # Check if game should be deleted
await self.cleanup_game() await self.cleanup_game()
@database_sync_to_async
def get_question_data(self):
quiz_game = QuizGame.objects.get(join_code=self.join_code)
current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
return json.loads(current_question.data)
async def receive(self, text_data): async def receive(self, text_data):
text_data_json = json.loads(text_data) text_data_json = json.loads(text_data)
message_type = text_data_json['type'] message_type = text_data_json['type']
@@ -84,14 +92,16 @@ class GameConsumer(AsyncWebsocketConsumer):
elif message_type == 'submit_answer': elif message_type == 'submit_answer':
participant_id = text_data_json['participant_id'] participant_id = text_data_json['participant_id']
try: answer = text_data_json['answer']
answer = text_data_json['answer']
except:
answer=-1
time_remaining = text_data_json.get('time_remaining', 0) time_remaining = text_data_json.get('time_remaining', 0)
# Save answer and update participant score
await self.save_answer(participant_id, answer, time_remaining) question_data = await self.get_question_data()
if question_data['type'] == "map":
await self.save_answer_map(participant_id, answer, time_remaining)
else:
await self.save_answer(participant_id, answer, time_remaining)
# Notify host about the new answer # Notify host about the new answer
await self.channel_layer.group_send( await self.channel_layer.group_send(
@@ -182,6 +192,97 @@ class GameConsumer(AsyncWebsocketConsumer):
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:
return False return False
@database_sync_to_async
def save_answer_map(self, participant_id, answer_location, time_remaining):
answer_index = -1
try:
# Get all necessary data first
participant = QuizGameParticipant.objects.get(
quiz_game__join_code=self.join_code,
participant_id=participant_id
)
quiz_game = participant.quiz_game
current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
question_data = json.loads(current_question.data)
target_location = question_data['target_location']
tolerance_radius = question_data['tolerance_radius']
print(f"target_location: {target_location}")
print(f"answer_location: {answer_location}")
print(f"tolerance_radius: {tolerance_radius}")
# Configuration - Make these easily adjustable
MAX_DISTANCE_KM = 1000 # Maximum distance for scoring (in km)
DIRECT_HIT_BONUS = 200 # Extra points for direct hit within tolerance
# Calculate base score
distance_km = geodesic(answer_location, target_location).kilometers
tolerance_km = int(tolerance_radius) / 1000 # Convert meters to kilometers
# Calculate base score with non-linear decrease
if distance_km <= tolerance_km:
# Direct hit - full points + bonus
base_score = 1000 + DIRECT_HIT_BONUS # 1000 + 200 bonus
is_correct = True
else:
if distance_km > MAX_DISTANCE_KM:
# Beyond max distance - 0 points
base_score = 0
is_correct = False
else:
# Non-linear decrease using exponential falloff
# This creates a steeper drop-off at the beginning
progress = (distance_km - tolerance_km) / (MAX_DISTANCE_KM - tolerance_km)
# Using a power function to create non-linear falloff
falloff = progress ** 1.5 # 1.5 makes the falloff steeper at the beginning
base_score = 1000 * (1 - falloff)
is_correct = True
base_score = max(0, min(1000, int(base_score)))
# Apply time penalty (reduces score based on time taken)
if is_correct and base_score > 0:
# Calculate time taken as a fraction of total time (0-1)
time_taken_sec = (int(current_question.time_per_question) * 1000 - time_remaining) / 1000
max_time_sec = int(current_question.time_per_question)
time_fraction = min(1.0, time_taken_sec / max_time_sec) if max_time_sec > 0 else 1.0
# Reduce points based on time taken (up to 50% reduction for answering at the last second)
time_penalty = base_score * 0.5 * time_fraction # Up to 50% penalty
final_score = max(1, base_score - time_penalty) # At least 1 point if correct
else:
final_score = base_score # 0 points for incorrect answers
score = int(round(final_score))
# Save the answer
answer_obj, created = QuizAnswer.objects.get_or_create(
participant=participant,
question_index=quiz_game.current_question_index,
defaults={
'answer_index': answer_index,
'is_correct': is_correct,
'score': score,
'time_remaining': time_remaining
}
)
if not created:
answer_obj.answer_index = answer_index
answer_obj.is_correct = is_correct
answer_obj.score = score
answer_obj.time_remaining = time_remaining
answer_obj.save()
participant.last_score = score
participant.score += score
participant.last_answer_correct = is_correct
participant.save()
return True
except (QuizGameParticipant.DoesNotExist, IndexError):
return False
@database_sync_to_async @database_sync_to_async
def save_answer(self, participant_id, answer, time_remaining): def save_answer(self, participant_id, answer, time_remaining):
try: try:

View File

@@ -124,4 +124,13 @@ div.qp-question-container {
div.qp-question-container img { div.qp-question-container img {
@apply max-w-[50vw] max-h-[40vh] mx-auto; @apply max-w-[50vw] max-h-[40vh] mx-auto;
}
div#mapquestion-container {
display: flex;
justify-content: center;
}
div#map {
@apply md:w-[600px] lg:w-[900px] h-[400px]
} }

View File

@@ -9,6 +9,9 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
<title>qivip</title> <title>qivip</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
</head> </head>
<body class="flex flex-col min-h-screen"> <body class="flex flex-col min-h-screen">
<main class="grow"> <main class="grow">

View File

@@ -98,9 +98,6 @@
Eingabe Eingabe
</a> </a>
<a href="{% url 'library:new_question' %}?type=multiple_choice&quiz_id={{ quiz.id }}" <a href="{% url 'library:new_question' %}?type=multiple_choice&quiz_id={{ quiz.id }}"
class=" mt-1 bg-blue-100 text-blue-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2"> class=" mt-1 bg-blue-100 text-blue-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
Multiple Choice Multiple Choice
@@ -116,10 +113,15 @@
Reihenfolge Reihenfolge
</a> </a>
<a href="{% url 'library:new_question' %}?type=guess&quiz_id={{ quiz.id }}" <a href="{% url 'library:new_question' %}?type=guess&quiz_id={{ quiz.id }}"
class=" mt-1 bg-pink-100 text-pink-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2"> class=" mt-1 bg-pink-100 text-pink-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
Schätzen Schätzen
</a> </a>
<a href="{% url 'library:new_question' %}?type=map&quiz_id={{ quiz.id }}"
class=" mt-1 bg-purple-100 text-orange-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-orange-600 border-2">
Karte
</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -140,8 +142,14 @@
<div class="flex-grow"> <div class="flex-grow">
<p class="font-medium">{{ question.data.question }}</p> <p class="font-medium">{{ question.data.question }}</p>
<div class="mt-1"> <div class="mt-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {% if question.data.type == 'multiple_choice' %}bg-blue-100 text-blue-800 {% elif question.data.type == 'input' %} bg-green-100 text-green-800 {% elif question.data.type == 'true_false' %}bg-purple-100 text-purple-800{% elif question.data.type == 'order' %}bg-yellow-100 text-yellow-800{% else %}bg-pink-100 text-pink-800 {% endif %}"> <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {% if question.data.type == 'multiple_choice' %}bg-blue-100 text-blue-800 {% elif question.data.type == 'input' %} bg-green-100 text-green-800 {% elif question.data.type == 'map' %}bg-orange-100 text-orange-800 {% elif question.data.type == 'true_false' %}bg-purple-100 text-purple-800{% elif question.data.type == 'order' %}bg-yellow-100 text-yellow-800{% else %}bg-pink-100 text-pink-800 {% endif %}">
{% if question.data.type == 'multiple_choice' %}Multiple Choice{% elif question.data.type == 'input' %}Eingabe{% elif question.data.type == 'true_false' %}Wahr/Falsch{% elif question.data.type == 'order' %}Reihenfolge{% else %}Schätzen{% endif %} {% if question.data.type == 'multiple_choice' %}Multiple Choice
{% elif question.data.type == 'input' %}Eingabe
{% elif question.data.type == 'map' %}Karte
{% elif question.data.type == 'true_false' %}Wahr/Falsch
{% elif question.data.type == 'order' %}Reihenfolge
{% else %}Schätzen
{% endif %}
</span> </span>
</div> </div>
<div class="mt-2"> <div class="mt-2">
@@ -173,8 +181,46 @@
<img src="{{ question.image.image.url }}" alt="Bild der Frage" class="w-[200px] rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md object-contain mt-4 md:mt-0" /> <img src="{{ question.image.image.url }}" alt="Bild der Frage" class="w-[200px] rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md object-contain mt-4 md:mt-0" />
{% endif %} {% endif %}
</div> </div>
{% elif question.data.type == 'map' and detail %}
<div class="mt-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="font-medium">Zielort:</span>
<span id="target-location-{{ forloop.counter }}">
{% if question.data.target_location %}
{{ question.data.target_location }}
{% else %}
Kein Zielort festgelegt
{% endif %}
</span>
</div>
{% if question.data.target_location %}
<script>
// Format the coordinates to 4 decimal places
document.addEventListener('DOMContentLoaded', function() {
const targetEl = document.getElementById('target-location-{{ forloop.counter }}');
if (targetEl) {
const coords = targetEl.textContent.trim().split(',');
if (coords.length === 2) {
const lat = parseFloat(coords[0]).toFixed(4);
const lng = parseFloat(coords[1]).toFixed(4);
targetEl.textContent = `${lat}, ${lng}`;
}
}
});
</script>
{% endif %}
{% if question.data.tolerance_radius %}
<div class="text-sm text-gray-600 dark:text-gray-300 mt-1">
<span class="font-medium">Toleranzradius:</span> {{ question.data.tolerance_radius }} Meter
</div>
{% endif %}
{% if question.image.image.url %}
<img src="{{ question.image.image.url }}" alt="Bild der Frage" class="w-[200px] rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md object-contain mt-4" />
{% endif %}
</div>
{% elif question.data.type == 'order' %} {% elif question.data.type == 'order' %}
<div class="w-full gap-4 md:flex justify-between "> <div class="w-full gap-4 md:flex justify-between ">
<ul class="space-y-1 "> <ul class="space-y-1 ">
{% for option in question.data.options|dictsort:"order" %} {% for option in question.data.options|dictsort:"order" %}
<li class="flex items-center text-sm"> <li class="flex items-center text-sm">
@@ -188,7 +234,6 @@
{% endif %} {% endif %}
</div> </div>
{% elif question.data.type == 'input' %} {% elif question.data.type == 'input' %}
<div class="w-full gap-4 md:flex justify-between "> <div class="w-full gap-4 md:flex justify-between ">
<ul class="space-y-1 "> <ul class="space-y-1 ">

View File

@@ -0,0 +1,131 @@
{% extends 'base.html' %}
{% block content %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<div id="mapquestion-container">
<div>
<form class="flex flex-col p-4 space-y-4" enctype="multipart/form-data" method="post">
{% csrf_token %}
<div class="relative">
<label for="question" class="absolute -top-2 left-3 text-sm bg-white dark:bg-gray-900 px-1 text-blue-600 dark:text-white z-10">Frage</label>
<input type="text" id="question" name="question" class="p-4 w-full border-2 border-blue-400 rounded-md dark:text-white" value="{{ question.data.question }}" />
</div>
{% if image %}
<img src="{{ image.image.url }}" style="max-width: 100px;" alt="Bild der Frage">
<label for="reset_image" class="dark:text-white">
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
Bild zurücksetzen
</label>
{% endif %}
<div class="relative">
<label for="question_image" class="absolute -top-2 left-3 text-sm bg-white dark:bg-gray-900 px-1 text-blue-600 dark:text-white z-10">Bild der Frage</label>
<input class="p-4 w-full border-2 border-blue-400 rounded-md dark:text-white" type="file" name="question_image" id="question_image" accept="image/*">
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">Ziel auswählen</div>
<div id="map"></div>
<div class="relative">
<label for="location" class="absolute -top-2 left-3 text-sm bg-white dark:bg-gray-900 px-1 text-blue-600 dark:text-white z-10">Koordinaten</label>
<input type="text" id="location" name="location" class="p-4 w-full border-2 border-blue-400 rounded-md dark:text-white" value="{{ question.data.target_location }}" />
</div>
<div class="relative">
<label for="time_per_question" class="absolute -top-2 left-3 text-sm bg-white dark:bg-gray-900 px-1 text-blue-600 dark:text-white z-10">Zeit pro Frage</label>
<select name="time_per_question" id="time_per_question" class="p-4 w-full border-2 border-blue-400 rounded-md bg-white dark:bg-gray-900 dark:text-gray-300">
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
</select>
</div>
<div class="relative">
<label for="credits" class="absolute -top-2 left-3 text-sm bg-white dark:bg-gray-900 px-1 text-blue-600 dark:text-white z-10">Credits</label>
<textarea name="credits" id="credits" rows="4" class="p-4 w-full border-2 border-blue-400 rounded-md dark:text-white">{{ credits }}</textarea>
</div>
<div class="relative">
<label for="tolerance_radius" class="absolute -top-2 left-3 text-sm bg-white dark:bg-gray-900 px-1 text-blue-600 dark:text-white z-10">Radius (Meter) für volle Punktzahl</label>
<input type="number" id="tolerance_radius" name="tolerance_radius" class="p-4 w-full border-2 border-blue-400 rounded-md dark:text-white" min="1" value="{{ question.data.tolerance_radius }}" />
</div>
<div class="flex justify-between mt-4">
<button class="p-2 rounded-md text-white bg-gray-600 hover:bg-gray-700" type="button" onclick="window.history.back()">Zurück</button>
<input class="p-2 rounded-md text-white bg-blue-400 hover:bg-blue-500" type="submit" value="Speichern">
</div>
</form>
</div>
</div>
<script>
let marker = null;
let circle = null;
const locationInput = document.getElementById('location');
const radiusInput = document.getElementById('tolerance_radius');
const map = L.map('map').setView([51.72, 10.45], 3);
// Prüfe, ob Dark Mode aktiv ist
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// Wähle das passende Karten-Design
const tileUrl = isDarkMode
? 'https://{s}.basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}.png';
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: '© OpenStreetMap © CARTO'
}).addTo(map);
function updateCircle(latlng) {
const radius = parseInt(radiusInput.value) || 100;
if (circle) {
map.removeLayer(circle);
}
circle = L.circle(latlng, {
color: 'blue',
fillColor: '#87CEEB',
fillOpacity: 0.2,
radius: radius
}).addTo(map);
}
function updateInput(latlng) {
locationInput.value = `${latlng.lat.toFixed(6)},${latlng.lng.toFixed(6)}`;
if (marker) {
updateCircle(marker.getLatLng());
}
}
function placeOrMoveMarker(latlng) {
if (marker) {
marker.setLatLng(latlng);
} else {
marker = L.marker(latlng, { draggable: true }).addTo(map);
marker.on('dragend', () => {
updateInput(marker.getLatLng());
});
}
updateInput(latlng);
}
// Update circle when radius changes
radiusInput.addEventListener('input', () => {
if (marker) {
updateCircle(marker.getLatLng());
}
});
// Place marker at existing coordinates if available
const existingCoords = '{{ question.data.target_location }}';
if (existingCoords) {
const [lat, lng] = existingCoords.split(',').map(Number);
placeOrMoveMarker({ lat, lng });
map.setView([lat, lng], 6); // Zoom in to show the marker
}
map.on('click', e => placeOrMoveMarker(e.latlng));
</script>
{% endblock %}

View File

@@ -50,8 +50,7 @@
</div> </div>
{% if question_data.type == "multiple_choice" or question_data.type == "true_false" %} {% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
<div class="grid grid-cols-2 gap-4" id="options-container"> <div class="grid grid-cols-2 gap-4" id="options-container">
{% for option in question_data.options %} {% for option in question_data.options %}
<button <button
@@ -162,8 +161,93 @@ class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-
Abschicken Abschicken
</button> </button>
</form> </form>
{% endif %}
{% elif question_data.type == "guess" %} {% endif %}
{% elif question_data.type == "map" and request.session.mode == "learn" %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">Ziel auswählen</div>
<div id="map" class="h-96"></div>
<input type="hidden" value="" id="singleanswer" name="singleanswer">
<div class="mt-4">
<button onclick="submitAnswerMap()" class="bg-blue-400 text-white px-4 py-2 rounded-md hover:bg-blue-500 transition-colors">
Abgeben
</button>
</div>
<input type="hidden" id="location" name="location" value="">
<script>
let marker = null;
const locationInput = document.getElementById('location');
const map = L.map('map').setView([51.72, 10.45], 3);
// Prüfe, ob Dark Mode aktiv ist
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// Wähle das passende Karten-Design
const tileUrl = isDarkMode
? 'https://{s}.basemaps.cartocdn.com/rastertiles/dark_nolabels/{z}/{x}/{y}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/light_nolabels/{z}/{x}/{y}.png';
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: '© OpenStreetMap © CARTO'
}).addTo(map);
function updateInput(latlng) {
locationInput.value = `${latlng.lat.toFixed(6)},${latlng.lng.toFixed(6)}`;
}
function placeOrMoveMarker(latlng) {
if (marker) {
marker.setLatLng(latlng);
} else {
marker = L.marker(latlng, { draggable: true }).addTo(map);
marker.on('dragend', () => {
updateInput(marker.getLatLng());
});
}
updateInput(latlng);
}
map.on('click', e => placeOrMoveMarker(e.latlng));
function submitAnswerMap() {
const answer = document.getElementById("location").value;
if (answer === "") {
alert("Bitte einen Ort auf der Karte auswählen!");
return;
}
// Add grey overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
overlay.style.zIndex = '1000';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.color = 'white';
overlay.style.fontSize = '24px';
overlay.style.fontWeight = 'bold';
overlay.textContent = 'Antwort übermittelt';
document.body.appendChild(overlay);
const now = Date.now();
const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
// Submit the answer
submitAnswer(answer);
}
</script>
{% elif question_data.type == "map" and request.session.mode != "learn" %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2 text-center">Bitte den gesuchten Ort auf der Karte auswählen</p>
{% elif question_data.type == "guess" %}
{% if request.session.mode == "learn" %} {% if request.session.mode == "learn" %}
<form class="flex flex-col items-center gap-4 mt-4"> <form class="flex flex-col items-center gap-4 mt-4">
<!-- Slider mit Beschriftung --> <!-- Slider mit Beschriftung -->
@@ -193,11 +277,7 @@ class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-
</form> </form>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -25,8 +25,7 @@
<div class="w-full bg-gray-300 rounded-full h-4 my-4"> <div class="w-full bg-gray-300 rounded-full h-4 my-4">
<div id="time-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-100 linear" style="width: 100%;"></div> <div id="time-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-100 linear" style="width: 100%;"></div>
</div> </div>
{% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
{% if question_data.type == "multiple_choice" or question_data.type == "true_false" %}
<div class="grid grid-cols-2 gap-4" id="options-container"> <div class="grid grid-cols-2 gap-4" id="options-container">
{% for option in question_data.options %} {% for option in question_data.options %}
<button data-index="{{ forloop.counter0 }}" <button data-index="{{ forloop.counter0 }}"
@@ -168,6 +167,58 @@
Abschicken Abschicken
</button> </button>
</form> </form>
{% elif question_data.type == "map" %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">Ziel auswählen</div>
<div id="map" class="h-96"></div>
<input type="hidden" value="" id="location" name="location">
<div class="mt-4">
<button onclick="submitAnswerMap()" class="bg-blue-400 text-white px-4 py-2 rounded-md hover:bg-blue-500 transition-colors">
Abgeben
</button>
</div>
<script>
let marker = null;
const locationInput = document.getElementById('location');
const map = L.map('map').setView([51.72, 10.45], 3);
// Prüfe, ob Dark Mode aktiv ist
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// Wähle das passende Karten-Design
const tileUrl = isDarkMode
? 'https://{s}.basemaps.cartocdn.com/rastertiles/dark_nolabels/{z}/{x}/{y}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/light_nolabels/{z}/{x}/{y}.png';
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: '© OpenStreetMap © CARTO'
}).addTo(map);
function updateInput(latlng) {
locationInput.value = `${latlng.lat.toFixed(6)},${latlng.lng.toFixed(6)}`;
}
function placeOrMoveMarker(latlng) {
if (marker) {
marker.setLatLng(latlng);
} else {
marker = L.marker(latlng, { draggable: true }).addTo(map);
marker.on('dragend', () => {
updateInput(marker.getLatLng());
});
}
updateInput(latlng);
}
map.on('click', e => placeOrMoveMarker(e.latlng));
</script>
{% else %}
<p>aaaaah gibts nicht</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -203,7 +254,22 @@
wsUtil.handleClose(e); wsUtil.handleClose(e);
}; };
function submitAnswer(optionIndex) { function submitAnswer(optionIndex=0, has_options=true) {
if (!has_options){
answer = document.getElementById("singleanswer").value;
if (answer == ""){
alert("Bitte eine Antwort eingeben!");
return;
}
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer:answer,
time_remaining: questionDuration
}));
}
if (hasAnswered) return; if (hasAnswered) return;
let input = ""; // Declare input here let input = ""; // Declare input here
@@ -249,7 +315,42 @@
} }
} }
function submitAnswerMap() {
answer = document.getElementById("location").value;
if (answer == ""){
alert("Bitte eine Antwort eingeben!");
return;
}
// Add grey overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(128, 128, 128, 0.5)';
overlay.style.zIndex = '1000';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.color = 'white';
overlay.style.fontSize = '24px';
overlay.style.fontWeight = 'bold';
overlay.textContent = 'Antwort übermittelt';
document.body.appendChild(overlay);
const now = Date.now();
const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer: answer,
time_remaining: timeRemaining
}));
}
// Timer // Timer
function updateTimer() { function updateTimer() {

View File

@@ -9,9 +9,100 @@
<h2 class="text-xl font-bold mb-4">Aktuelle Frage</h2> <h2 class="text-xl font-bold mb-4">Aktuelle Frage</h2>
<p class="text-lg mb-2">{{ question_data.question }}</p> <p class="text-lg mb-2">{{ question_data.question }}</p>
{% if question_data.type != "order" and question_data.type != "guess" %}
{% if question_data.type == "map" %}
<!-- Map Question Display -->
<div class="mb-4">
<div id="map" class="h-96 w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Parse the target location from the string format "lat,lng"
const targetCoords = '{{ question_data.target_location|escapejs }}'.split(',');
let targetLat, targetLng;
if (targetCoords.length === 2) {
targetLat = parseFloat(targetCoords[0].trim());
targetLng = parseFloat(targetCoords[1].trim());
// Normalize longitude to be within -180 to 180
targetLng = ((targetLng % 360) + 540) % 360 - 180;
// Clamp latitude to valid range (-90 to 90)
targetLat = Math.max(-90, Math.min(90, targetLat));
console.log('Normalized coordinates:', { lat: targetLat, lng: targetLng });
}
// Validate coordinates
if (isNaN(targetLat) || isNaN(targetLng)) {
console.warn('Invalid coordinates, hiding map');
document.getElementById('map-container').style.display = 'none';
return; // Exit the function early
}
const targetLocation = [targetLat, targetLng];
console.log('Using coordinates:', targetLocation);
// Initialize map centered on target location
const map = L.map('map').setView(targetLocation, 7);
// Check for dark mode
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
// Choose appropriate map style
const tileUrl = isDarkMode
? 'https://{s}.basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}.png';
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: '© OpenStreetMap © CARTO',
noWrap: true
}).addTo(map);
// Add target marker
L.marker(targetLocation, {
icon: L.divIcon({
html: '🎯',
className: 'text-3xl',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
})
}).addTo(map)
.bindPopup('Zielort')
.openPopup();
// Add tolerance radius circle if available
{% if question_data.tolerance_radius %}
(function() {
try {
const toleranceRadius = parseFloat('{{ question_data.tolerance_radius|escapejs }}'.replace(',', '.'));
if (!isNaN(toleranceRadius) && toleranceRadius > 0) {
L.circle(targetLocation, {
color: isDarkMode ? '#3b82f6' : '#2563eb',
fillColor: isDarkMode ? '#3b82f6' : '#2563eb',
fillOpacity: 0.2,
radius: toleranceRadius
}).addTo(map);
console.log('Added tolerance radius:', toleranceRadius);
}
} catch (e) {
console.error('Error adding tolerance radius:', e);
}
})();
{% endif %}
});
</script>
{% elif question_data.type != "order" and question_data.type != "guess" %}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
{% for option in question_data.options %} {% for option in question_data.options %}
<div id="option-box-{{ forloop.counter0 }}" class=" dark:bg-transparent relative p-4 rounded-lg overflow-auto break-words hyphens-auto {% if option.is_correct %} border-2 border-green-500{% else %}border-2 border-red-500{% endif %}"> <div id="option-box-{{ forloop.counter0 }}" class=" dark:bg-transparent relative p-4 rounded-lg overflow-auto break-words hyphens-auto {% if option.is_correct %} border-2 border-green-500{% else %}border-2 border-red-500{% endif %}">
@@ -25,7 +116,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% elif question_data.type == "guess" %} {% elif question_data.type == "guess" %}
<div id="option-box-0" class="relative p-4 rounded-lg border-2 border-green-500 overflow-auto"> <div id="option-box-0" class="relative p-4 rounded-lg border-2 border-green-500 overflow-auto">
<div id="option-fill-0" class="absolute left-0 top-0 h-full bg-green-300 opacity-50 z-0 transition-all duration-1500" style="width: 0%;"></div> <div id="option-fill-0" class="absolute left-0 top-0 h-full bg-green-300 opacity-50 z-0 transition-all duration-1500" style="width: 0%;"></div>
<div class="relative z-10"> <div class="relative z-10">

View File

@@ -11,4 +11,6 @@ python-dotenv # Production server safety
qrcode~=7.4.2 qrcode~=7.4.2
reportlab~=4.2.0 reportlab~=4.2.0
pip-licenses ~= 5.0.0 pip-licenses ~= 5.0.0
requests requests
requests
geopy