Merge branch '85-fragentyp-ort-suchen' into 'master'

Resolve "Fragentyp: Ort suchen"

Closes #85

See merge request enrichment-2024/qivip!103
This commit is contained in:
ben8
2025-11-01 18:41:37 +00:00
13 changed files with 788 additions and 67 deletions

View File

@@ -17,6 +17,8 @@
| cryptography | 46.0.3 | UNKNOWN | | cryptography | 46.0.3 | UNKNOWN |
| daphne | 4.1.2 | BSD License | | daphne | 4.1.2 | BSD License |
| django-cleanup | 9.0.0 | MIT License | | django-cleanup | 9.0.0 | MIT License |
| geographiclib | 2.1 | MIT |
| geopy | 2.4.1 | MIT License |
| hyperlink | 21.0.0 | MIT License | | hyperlink | 21.0.0 | MIT License |
| idna | 3.11 | UNKNOWN | | idna | 3.11 | UNKNOWN |
| incremental | 24.7.2 | MIT License | | incremental | 24.7.2 | MIT License |

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':
@@ -957,9 +957,16 @@ def new_question(request):
] ]
} }
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
@@ -1042,6 +1049,14 @@ def new_question(request):
} }
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)
@@ -1211,6 +1233,16 @@ def edit_question(request, pk):
] ]
} }
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,15 +92,17 @@ 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
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) 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(
self.game_group_name, self.game_group_name,
@@ -182,6 +192,138 @@ 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 = 0
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.get('tolerance_radius', 1000) # Default 1km if not set
# Parse coordinates if they are strings
def parse_coords(coord):
if isinstance(coord, str):
try:
# Handle both comma and space separated coordinates
if ',' in coord:
lat, lng = map(float, [x.strip() for x in coord.split(',')])
else:
# Try splitting by space if no comma
parts = coord.strip().split()
if len(parts) >= 2:
lat, lng = map(float, parts[:2])
else:
raise ValueError("Invalid coordinate format")
return (lat, lng)
except (ValueError, AttributeError) as e:
print(f"Error parsing coordinates {coord}: {e}")
return None
elif isinstance(coord, (list, tuple)) and len(coord) >= 2:
return (float(coord[0]), float(coord[1]))
return None
# Parse the coordinates
target_location = parse_coords(target_location)
answer_location_coords = answer_location
answer_location = parse_coords(answer_location)
if not target_location or not answer_location:
print("Invalid coordinates, cannot calculate score")
is_correct = False
score = 0
else:
# Configuration - Make these easily adjustable
MAX_DISTANCE_KM = 200 # Maximum distance for scoring (in km)
DIRECT_HIT_BONUS = 200 # Extra points for direct hit within tolerance
try:
# Calculate distance
distance_km = geodesic(answer_location, target_location).kilometers
tolerance_km = float(tolerance_radius) / 1000 # Convert meters to kilometers
print(f"Distance: {distance_km} km, Tolerance: {tolerance_km} km")
# Calculate base score with non-linear decrease
if distance_km <= tolerance_km:
# Direct hit - full points + bonus
base_score = 1000 + DIRECT_HIT_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
progress = (distance_km - tolerance_km) / (MAX_DISTANCE_KM - tolerance_km)
falloff = progress ** 1.5 # 1.5 makes the falloff steeper at the beginning
base_score = 1000 * (1 - falloff)
is_correct = True
# 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
# Apply max score limit after all calculations (including bonus)
final_score = min(final_score, 1200) # Cap at 1200 (1000 + 200 bonus)
score = int(round(final_score))
except Exception as e:
print(f"Error calculating score: {e}")
is_correct = False
score = 0
# 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.coordinate_answer= answer_location_coords
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

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-11-01 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0015_alter_quizgame_current_state'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='coordinate_answer',
field=models.CharField(blank=True, max_length=256, null=True),
),
]

View File

@@ -50,6 +50,7 @@ class QuizGameParticipant(models.Model):
avatar = models.CharField(max_length=200, blank=True, default="") avatar = models.CharField(max_length=200, blank=True, default="")
last_heartbeat = models.DateTimeField(auto_now_add=True) last_heartbeat = models.DateTimeField(auto_now_add=True)
last_answer = models.IntegerField(null=True, blank=True) last_answer = models.IntegerField(null=True, blank=True)
coordinate_answer = models.CharField(null=True, blank=True,max_length=256)
last_answer_correct = models.BooleanField(null=True, blank=True) last_answer_correct = models.BooleanField(null=True, blank=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@@ -125,3 +125,12 @@ 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
@@ -120,6 +117,11 @@
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-orange-100 text-orange-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-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,6 +181,44 @@
<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 ">
@@ -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,166 @@
{% 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>
<div class="container mx-auto px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold dark:text-white">{% if question %}Frage bearbeiten{% else %}Neue Karten Frage{% endif %}</h1>
<a href="{% url 'library:detail_quiz' quiz.id %}"
class="bg-gray-100 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-200 transition-colors">
Zurück zum Quiz
</a>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md dark:bg-transparent dark:text-white">
<form method="post" class="space-y-6" enctype="multipart/form-data">
{% csrf_token %}
<!-- Question Text -->
<div>
<label for="question" class="block text-sm font-medium text-gray-700 dark:text-white">Frage</label>
<div class="mt-1">
<textarea name="question" id="question" rows="4"
class="dark:bg-[#2a2f3a] shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
required>{{ question.data.question }}</textarea>
</div>
</div>
{% if image %}
<img src="{{ image.image.url }}" style="max-width: 100px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="question_image">Bild:</label>
<input class="max-w-full bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400 dark:bg-transparent" type="file" name="question_image" id="question_image">
</div>
<label for="map" class="block text-sm font-medium text-gray-700 dark:text-white">Ziel auswählen</label>
<div id="map"></div>
<label for="location" class="block text-sm font-medium text-gray-700 dark:text-white">Koordinaten</label>
<div class="mt-1">
<textarea id="location" name="location" rows="2" required
class="dark:bg-[#2a2f3a] shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md "
>{{ question.data.target_location }}</textarea>
</div>
<label for="tolerance_radius" class="block text-sm font-medium text-gray-700 dark:text-white">Radius (Meter) für volle Punktzahl</label>
<div class="mt-1">
<textarea id="tolerance_radius" name="tolerance_radius" rows="2" required
class="dark:bg-[#2a2f3a] shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md "
>{{ question.data.tolerance_radius }}</textarea>
</div>
<div class="p-4">
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
<select name="time_per_question" id="time_per_question" class="dark:text-white dark:bg-[#2a2f3a] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<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>
<label for="credits" class="block text-sm font-medium text-gray-700 dark:text-white">Credits</label>
<div class="mt-1">
<textarea name="credits" rows="4"
class="dark:bg-[#2a2f3a] shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md "
>{{ credits }}</textarea>
</div>
<div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Abbrechen
</a>
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{% if question %}Speichern{% else %}Erstellen{% endif %}
</button>
</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

@@ -51,7 +51,6 @@
</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,7 +161,92 @@ class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-
Abschicken Abschicken
</button> </button>
</form> </form>
{% endif %} {% 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" %} {% 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">
@@ -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 %}
@@ -232,7 +312,7 @@ class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-semibold rounded-
setTimeout(() => { setTimeout(() => {
submitAnswer( -1 ); submitAnswer(-1);
window.location.href = data.redirect_url; window.location.href = data.redirect_url;
}, 1000); }, 1000);
} }

View File

@@ -25,7 +25,6 @@
<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 %}
@@ -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>
@@ -193,7 +244,7 @@
window.location.href = data.redirect_url; window.location.href = data.redirect_url;
{% if question_data.type == "order" %} {% if question_data.type == "order" %}
submitOrderAnswer(); submitOrderAnswer();
{% else %} {% elif question_data.type != "map" %}
submitAnswer(-1); submitAnswer(-1);
{% endif %} {% endif %}
} }
@@ -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,129 @@
<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" %}
<div class="grid grid-cols-2 gap-4">
{% 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>
const participantAnswers = [
{% for participant in participants %}
{% if participant.coordinate_answer %}
"{{ participant.coordinate_answer }}",
{% endif %}
{% endfor %}];
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);
participantAnswers.forEach(coordStr => {
const coords = coordStr.split(',').map(Number);
if (coords.length === 2 && !coords.some(isNaN)) {
const marker = L.marker(coords).addTo(map).bindPopup('Antwort eines Teilnehmers');
// Linie vom Ziel zum Teilnehmermarker
L.polyline([targetLocation, coords], {
color: '#f59e0b',
weight: 2, // Linienstärke
dashArray: '5,5', // gestrichelte Linie
opacity: 0.6
}).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">
{% 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 %}">

View File

@@ -12,3 +12,5 @@ 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