feat: improve eval viz
This commit is contained in:
@@ -366,8 +366,41 @@ class ConfigManager:
|
||||
return self.default_config['grading_system']['types']
|
||||
|
||||
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Récupère les valeurs spéciales configurables (., d, a)."""
|
||||
return self.default_config['grading_system']['special_values']
|
||||
"""Récupère les valeurs spéciales configurables (., d, a) depuis la base de données."""
|
||||
# Récupérer les valeurs spéciales depuis la base de données
|
||||
special_values_keys = ['.', 'd', 'a']
|
||||
scale_values = CompetenceScaleValue.query.filter(
|
||||
CompetenceScaleValue.value.in_(special_values_keys)
|
||||
).all()
|
||||
|
||||
# Si aucune valeur n'existe, initialiser avec les valeurs par défaut
|
||||
if not scale_values:
|
||||
self.initialize_default_config()
|
||||
scale_values = CompetenceScaleValue.query.filter(
|
||||
CompetenceScaleValue.value.in_(special_values_keys)
|
||||
).all()
|
||||
|
||||
# Construire le dict avec mapping included_in_total -> counts
|
||||
result = {}
|
||||
for scale_value in scale_values:
|
||||
# Récupérer les valeurs par défaut pour compléter les informations manquantes
|
||||
default_config = self.default_config['grading_system']['special_values'].get(scale_value.value, {})
|
||||
|
||||
result[scale_value.value] = {
|
||||
'label': scale_value.label,
|
||||
'color': scale_value.color,
|
||||
'counts': scale_value.included_in_total, # Mapping: included_in_total -> counts
|
||||
'description': default_config.get('description', scale_value.label),
|
||||
'value': default_config.get('value', 0 if scale_value.included_in_total else None)
|
||||
}
|
||||
|
||||
# Ajouter les valeurs manquantes depuis la config par défaut si elles n'existent pas en base
|
||||
default_special = self.default_config['grading_system']['special_values']
|
||||
for key, default_value in default_special.items():
|
||||
if key not in result:
|
||||
result[key] = default_value
|
||||
|
||||
return result
|
||||
|
||||
def get_score_meanings(self) -> Dict[int, Dict[str, str]]:
|
||||
"""Récupère les significations des scores (0-3) depuis la base de données."""
|
||||
|
||||
@@ -193,10 +193,86 @@ def results(id):
|
||||
|
||||
# Calculer les scores des élèves
|
||||
students_scores, exercise_scores = assessment.calculate_student_scores()
|
||||
|
||||
|
||||
# Trier les élèves par ordre alphabétique
|
||||
sorted_students = sorted(students_scores.values(),
|
||||
sorted_students = sorted(students_scores.values(),
|
||||
key=lambda x: (x['student'].last_name.lower(), x['student'].first_name.lower()))
|
||||
|
||||
# Enrichir les données avec les valeurs spéciales pour l'affichage
|
||||
from app_config import config_manager
|
||||
scale_colors = config_manager.get_competence_scale_values()
|
||||
|
||||
for student_data in sorted_students:
|
||||
student = student_data['student']
|
||||
# Analyser les valeurs spéciales pour chaque exercice
|
||||
for exercise in assessment.exercises:
|
||||
if exercise.id in student_data['exercises']:
|
||||
exercise_data = student_data['exercises'][exercise.id]
|
||||
|
||||
# Collecter toutes les valeurs pour cet exercice
|
||||
all_values = []
|
||||
has_counted_values = False
|
||||
|
||||
for element in exercise.grading_elements:
|
||||
grade = None
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id:
|
||||
grade = g
|
||||
break
|
||||
|
||||
if grade and grade.value:
|
||||
all_values.append(grade.value)
|
||||
# Vérifier si cette valeur compte dans le calcul
|
||||
from models import GradingCalculator
|
||||
if GradingCalculator.is_counted_in_total(grade.value, element.grading_type):
|
||||
has_counted_values = True
|
||||
|
||||
# Si toutes les valeurs sont spéciales et identiques ET qu'aucune ne compte dans le total
|
||||
if all_values and not has_counted_values:
|
||||
unique_values = set(all_values)
|
||||
if len(unique_values) == 1:
|
||||
unique_value = next(iter(unique_values))
|
||||
if unique_value in scale_colors:
|
||||
exercise_data['special_display'] = unique_value
|
||||
else:
|
||||
exercise_data['special_display'] = None
|
||||
else:
|
||||
exercise_data['special_display'] = None
|
||||
else:
|
||||
exercise_data['special_display'] = None
|
||||
|
||||
# Analyser les valeurs spéciales pour le total global
|
||||
all_student_values = []
|
||||
has_any_counted_values = False
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
grade = None
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id:
|
||||
grade = g
|
||||
break
|
||||
|
||||
if grade and grade.value:
|
||||
all_student_values.append(grade.value)
|
||||
# Vérifier si cette valeur compte dans le calcul
|
||||
from models import GradingCalculator
|
||||
if GradingCalculator.is_counted_in_total(grade.value, element.grading_type):
|
||||
has_any_counted_values = True
|
||||
|
||||
# Si toutes les valeurs sont spéciales et identiques ET qu'aucune ne compte dans le total
|
||||
if all_student_values and not has_any_counted_values:
|
||||
unique_values = set(all_student_values)
|
||||
if len(unique_values) == 1:
|
||||
unique_value = next(iter(unique_values))
|
||||
if unique_value in scale_colors:
|
||||
student_data['total_special_display'] = unique_value
|
||||
else:
|
||||
student_data['total_special_display'] = None
|
||||
else:
|
||||
student_data['total_special_display'] = None
|
||||
else:
|
||||
student_data['total_special_display'] = None
|
||||
|
||||
# Calculer les statistiques
|
||||
statistics = assessment.get_assessment_statistics()
|
||||
@@ -226,16 +302,19 @@ def results(id):
|
||||
students_list = [f"{s['student'].last_name} {s['student'].first_name}" for s in sorted_students]
|
||||
competences_list = sorted(competences_in_eval)
|
||||
|
||||
# Calculer les scores par élève/compétence
|
||||
# Calculer les scores par élève/compétence avec détection des valeurs spéciales
|
||||
competences_scores_matrix = []
|
||||
competences_special_values_matrix = [] # Nouvelle matrice pour les valeurs spéciales
|
||||
|
||||
for student_data in sorted_students:
|
||||
student_scores_by_competence = {}
|
||||
student_totals_by_competence = {}
|
||||
|
||||
student_special_values_by_competence = {} # Stocker les valeurs spéciales
|
||||
|
||||
for exercise_id, exercise_data in student_data['exercises'].items():
|
||||
# Récupérer l'exercice pour accéder aux grading_elements
|
||||
exercise = next(ex for ex in assessment.exercises if ex.id == exercise_id)
|
||||
|
||||
|
||||
for element in exercise.grading_elements:
|
||||
if element.skill and element.skill in competences_in_eval:
|
||||
# Trouver la note correspondante
|
||||
@@ -244,9 +323,20 @@ def results(id):
|
||||
if g.student_id == student_data['student'].id:
|
||||
grade = g
|
||||
break
|
||||
|
||||
|
||||
if grade and grade.value:
|
||||
from models import GradingCalculator
|
||||
|
||||
# Vérifier d'abord si c'est une valeur spéciale
|
||||
from app_config import config_manager
|
||||
scale_colors = config_manager.get_competence_scale_values()
|
||||
|
||||
if grade.value in scale_colors:
|
||||
# C'est une valeur spéciale
|
||||
if element.skill not in student_special_values_by_competence:
|
||||
student_special_values_by_competence[element.skill] = []
|
||||
student_special_values_by_competence[element.skill].append(grade.value)
|
||||
|
||||
score = GradingCalculator.calculate_score(grade.value, element.grading_type, element.max_points)
|
||||
if score is not None: # Exclure les dispensés
|
||||
if element.skill not in student_scores_by_competence:
|
||||
@@ -254,17 +344,30 @@ def results(id):
|
||||
student_totals_by_competence[element.skill] = 0
|
||||
student_scores_by_competence[element.skill] += score
|
||||
student_totals_by_competence[element.skill] += element.max_points
|
||||
|
||||
|
||||
# Calculer les pourcentages par compétence pour cet élève
|
||||
student_row = []
|
||||
student_special_row = []
|
||||
for comp in competences_list:
|
||||
if comp in student_scores_by_competence and student_totals_by_competence[comp] > 0:
|
||||
percentage = (student_scores_by_competence[comp] / student_totals_by_competence[comp]) * 100
|
||||
student_row.append(round(percentage, 1))
|
||||
|
||||
# Si toutes les notes sont des valeurs spéciales et identiques, utiliser cette valeur
|
||||
if comp in student_special_values_by_competence:
|
||||
special_values = student_special_values_by_competence[comp]
|
||||
if len(set(special_values)) == 1: # Toutes les valeurs sont identiques
|
||||
student_special_row.append(special_values[0])
|
||||
else:
|
||||
student_special_row.append(None)
|
||||
else:
|
||||
student_special_row.append(None)
|
||||
else:
|
||||
student_row.append(None) # Pas de données pour cette compétence
|
||||
|
||||
student_special_row.append(None)
|
||||
|
||||
competences_scores_matrix.append(student_row)
|
||||
competences_special_values_matrix.append(student_special_row)
|
||||
|
||||
# Préparer les données heatmap domaines
|
||||
domains_list = []
|
||||
@@ -277,29 +380,43 @@ def results(id):
|
||||
domains_list.append(domain_name)
|
||||
domains_colors[domain_name] = all_domains[domain_id]['color']
|
||||
|
||||
# Calculer les scores par élève/domaine
|
||||
# Calculer les scores par élève/domaine avec détection des valeurs spéciales
|
||||
domains_scores_matrix = []
|
||||
domains_special_values_matrix = [] # Nouvelle matrice pour les valeurs spéciales des domaines
|
||||
|
||||
for student_data in sorted_students:
|
||||
student_scores_by_domain = {}
|
||||
student_totals_by_domain = {}
|
||||
|
||||
student_special_values_by_domain = {} # Stocker les valeurs spéciales
|
||||
|
||||
for exercise_id, exercise_data in student_data['exercises'].items():
|
||||
# Récupérer l'exercice pour accéder aux grading_elements
|
||||
exercise = next(ex for ex in assessment.exercises if ex.id == exercise_id)
|
||||
|
||||
|
||||
for element in exercise.grading_elements:
|
||||
if element.domain_id and element.domain_id in domains_in_eval:
|
||||
domain_name = all_domains[element.domain_id]['name']
|
||||
|
||||
|
||||
# Trouver la note correspondante
|
||||
grade = None
|
||||
for g in element.grades:
|
||||
if g.student_id == student_data['student'].id:
|
||||
grade = g
|
||||
break
|
||||
|
||||
|
||||
if grade and grade.value:
|
||||
from models import GradingCalculator
|
||||
|
||||
# Vérifier d'abord si c'est une valeur spéciale
|
||||
from app_config import config_manager
|
||||
scale_colors = config_manager.get_competence_scale_values()
|
||||
|
||||
if grade.value in scale_colors:
|
||||
# C'est une valeur spéciale
|
||||
if domain_name not in student_special_values_by_domain:
|
||||
student_special_values_by_domain[domain_name] = []
|
||||
student_special_values_by_domain[domain_name].append(grade.value)
|
||||
|
||||
score = GradingCalculator.calculate_score(grade.value, element.grading_type, element.max_points)
|
||||
if score is not None: # Exclure les dispensés
|
||||
if domain_name not in student_scores_by_domain:
|
||||
@@ -307,17 +424,30 @@ def results(id):
|
||||
student_totals_by_domain[domain_name] = 0
|
||||
student_scores_by_domain[domain_name] += score
|
||||
student_totals_by_domain[domain_name] += element.max_points
|
||||
|
||||
|
||||
# Calculer les pourcentages par domaine pour cet élève
|
||||
student_row = []
|
||||
student_special_row = []
|
||||
for domain in domains_list:
|
||||
if domain in student_scores_by_domain and student_totals_by_domain[domain] > 0:
|
||||
percentage = (student_scores_by_domain[domain] / student_totals_by_domain[domain]) * 100
|
||||
student_row.append(round(percentage, 1))
|
||||
|
||||
# Si toutes les notes sont des valeurs spéciales et identiques, utiliser cette valeur
|
||||
if domain in student_special_values_by_domain:
|
||||
special_values = student_special_values_by_domain[domain]
|
||||
if len(set(special_values)) == 1: # Toutes les valeurs sont identiques
|
||||
student_special_row.append(special_values[0])
|
||||
else:
|
||||
student_special_row.append(None)
|
||||
else:
|
||||
student_special_row.append(None)
|
||||
else:
|
||||
student_row.append(None) # Pas de données pour ce domaine
|
||||
|
||||
student_special_row.append(None)
|
||||
|
||||
domains_scores_matrix.append(student_row)
|
||||
domains_special_values_matrix.append(student_special_row)
|
||||
|
||||
# Préparer les couleurs des compétences pour celles présentes dans l'évaluation
|
||||
competences_colors = {comp: all_competences.get(comp, '#6b7280') for comp in competences_list}
|
||||
@@ -326,13 +456,15 @@ def results(id):
|
||||
'students': students_list,
|
||||
'competences': competences_list,
|
||||
'scores': competences_scores_matrix,
|
||||
'special_values': competences_special_values_matrix,
|
||||
'colors': competences_colors
|
||||
} if competences_list else None
|
||||
|
||||
|
||||
heatmap_domains = {
|
||||
'students': students_list,
|
||||
'domains': domains_list,
|
||||
'scores': domains_scores_matrix,
|
||||
'special_values': domains_special_values_matrix,
|
||||
'colors': domains_colors
|
||||
} if domains_list else None
|
||||
|
||||
|
||||
@@ -73,46 +73,255 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap par compétences -->
|
||||
<!-- Tableau par compétences -->
|
||||
{% if heatmap_competences %}
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Performance par compétence</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Heatmap des scores moyens de chaque élève par compétence</p>
|
||||
<p class="text-sm text-gray-600 mt-1">Scores moyens de chaque élève par compétence (%) - Cliquez sur les en-têtes pour trier</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<canvas id="competencesHeatmap" width="800" height="400"></canvas>
|
||||
<table class="min-w-full sortable-table" id="competencesTable">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 sortable cursor-pointer hover:bg-gray-100" onclick="sortTable('competencesTable', 0)">
|
||||
Élève
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% for competence in heatmap_competences.competences %}
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-24 sortable cursor-pointer hover:bg-gray-100"
|
||||
style="color: {{ heatmap_competences.colors[competence] }}"
|
||||
onclick="sortTable('competencesTable', {{ loop.index }})">
|
||||
{{ competence }}
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student_index in range(heatmap_competences.students|length) %}
|
||||
<tr class="{% if loop.index0 % 2 == 0 %}bg-white{% else %}bg-gray-50{% endif %}">
|
||||
<td class="px-3 py-2 text-sm font-medium text-gray-900 sticky left-0 {% if loop.index0 % 2 == 0 %}bg-white{% else %}bg-gray-50{% endif %}">
|
||||
{{ heatmap_competences.students[student_index] }}
|
||||
</td>
|
||||
{% for competence_index in range(heatmap_competences.competences|length) %}
|
||||
{% set score = heatmap_competences.scores[student_index][competence_index] %}
|
||||
{% set special_value = heatmap_competences.special_values[student_index][competence_index] %}
|
||||
<td class="px-3 py-2 text-center text-sm"
|
||||
{% if special_value and special_value in heatmap_grading_elements.scale_colors %}
|
||||
{# Utiliser la couleur de la valeur spéciale #}
|
||||
style="background-color: {{ heatmap_grading_elements.scale_colors[special_value].color }};
|
||||
color: {% if heatmap_grading_elements.scale_colors[special_value].color in ['#ef4444', '#f97316', '#3b82f6', '#8b5cf6'] %}white{% else %}black{% endif %};"
|
||||
{% elif score is not none %}
|
||||
{# Utiliser le gradient pour les scores normaux #}
|
||||
style="background-color: {{ '#ef4444' if score <= 25 else ('#f97316' if score <= 50 else ('#eab308' if score <= 75 else '#22c55e')) }};
|
||||
color: {{ 'white' if score <= 50 else 'black' }};"
|
||||
{% endif %}>
|
||||
{% if special_value and special_value in heatmap_grading_elements.scale_colors %}
|
||||
{{ special_value }}
|
||||
{% elif score is not none %}
|
||||
{{ "%.1f"|format(score) }}%
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center space-x-4 text-xs">
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-red-500 mr-1"></div>
|
||||
<span>0-25%</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-orange-500 mr-1"></div>
|
||||
<span>26-50%</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-yellow-500 mr-1"></div>
|
||||
<span>51-75%</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-green-500 mr-1"></div>
|
||||
<span>76-100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<span class="text-xs text-gray-600">Les valeurs spéciales (., a, d, etc.) apparaissent avec leurs couleurs configurées</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Heatmap par domaines -->
|
||||
<!-- Tableau par domaines -->
|
||||
{% if heatmap_domains %}
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Performance par domaine</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Heatmap des scores moyens de chaque élève par domaine</p>
|
||||
<p class="text-sm text-gray-600 mt-1">Scores moyens de chaque élève par domaine (%) - Cliquez sur les en-têtes pour trier</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<canvas id="domainsHeatmap" width="800" height="400"></canvas>
|
||||
<table class="min-w-full sortable-table" id="domainsTable">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 sortable cursor-pointer hover:bg-gray-100" onclick="sortTable('domainsTable', 0)">
|
||||
Élève
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% for domain in heatmap_domains.domains %}
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-24 sortable cursor-pointer hover:bg-gray-100"
|
||||
style="color: {{ heatmap_domains.colors[domain] }}"
|
||||
onclick="sortTable('domainsTable', {{ loop.index }})">
|
||||
{{ domain }}
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student_index in range(heatmap_domains.students|length) %}
|
||||
<tr class="{% if loop.index0 % 2 == 0 %}bg-white{% else %}bg-gray-50{% endif %}">
|
||||
<td class="px-3 py-2 text-sm font-medium text-gray-900 sticky left-0 {% if loop.index0 % 2 == 0 %}bg-white{% else %}bg-gray-50{% endif %}">
|
||||
{{ heatmap_domains.students[student_index] }}
|
||||
</td>
|
||||
{% for domain_index in range(heatmap_domains.domains|length) %}
|
||||
{% set score = heatmap_domains.scores[student_index][domain_index] %}
|
||||
{% set special_value = heatmap_domains.special_values[student_index][domain_index] %}
|
||||
<td class="px-3 py-2 text-center text-sm"
|
||||
{% if special_value and special_value in heatmap_grading_elements.scale_colors %}
|
||||
{# Utiliser la couleur de la valeur spéciale #}
|
||||
style="background-color: {{ heatmap_grading_elements.scale_colors[special_value].color }};
|
||||
color: {% if heatmap_grading_elements.scale_colors[special_value].color in ['#ef4444', '#f97316', '#3b82f6', '#8b5cf6'] %}white{% else %}black{% endif %};"
|
||||
{% elif score is not none %}
|
||||
{# Utiliser le gradient pour les scores normaux #}
|
||||
style="background-color: {{ '#ef4444' if score <= 25 else ('#f97316' if score <= 50 else ('#eab308' if score <= 75 else '#22c55e')) }};
|
||||
color: {{ 'white' if score <= 50 else 'black' }};"
|
||||
{% endif %}>
|
||||
{% if special_value and special_value in heatmap_grading_elements.scale_colors %}
|
||||
{{ special_value }}
|
||||
{% elif score is not none %}
|
||||
{{ "%.1f"|format(score) }}%
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex items-center space-x-4 text-xs">
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-red-500 mr-1"></div>
|
||||
<span>0-25%</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-orange-500 mr-1"></div>
|
||||
<span>26-50%</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-yellow-500 mr-1"></div>
|
||||
<span>51-75%</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-green-500 mr-1"></div>
|
||||
<span>76-100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<span class="text-xs text-gray-600">Les valeurs spéciales (., a, d, etc.) apparaissent avec leurs couleurs configurées</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Heatmap par éléments de notation -->
|
||||
<!-- Tableau par éléments de notation -->
|
||||
{% if heatmap_grading_elements %}
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Performance par élément de notation</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Heatmap détaillée des scores de chaque élève sur chaque question/compétence</p>
|
||||
<p class="text-sm text-gray-600 mt-1">Détail des scores de chaque élève sur chaque question/compétence - Cliquez sur les en-têtes pour trier</p>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<canvas id="gradingElementsHeatmap" width="1200" height="600"></canvas>
|
||||
<table class="min-w-full text-xs sortable-table" id="elementsTable">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 min-w-32 sortable cursor-pointer hover:bg-gray-100" onclick="sortTable('elementsTable', 0)">
|
||||
Élève
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% for element in heatmap_grading_elements.elements %}
|
||||
<th class="px-1 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-20 sortable cursor-pointer hover:bg-gray-100"
|
||||
style="color: {{ heatmap_grading_elements.colors[element] }}"
|
||||
title="{{ element }}"
|
||||
onclick="sortTable('elementsTable', {{ loop.index }})">
|
||||
{{ element|truncate(12, True) }}
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student_index in range(heatmap_grading_elements.students|length) %}
|
||||
<tr class="{% if loop.index0 % 2 == 0 %}bg-white{% else %}bg-gray-50{% endif %}">
|
||||
<td class="px-2 py-2 text-xs font-medium text-gray-900 sticky left-0 {% if loop.index0 % 2 == 0 %}bg-white{% else %}bg-gray-50{% endif %}">
|
||||
{{ heatmap_grading_elements.students[student_index] }}
|
||||
</td>
|
||||
{% for element_index in range(heatmap_grading_elements.elements|length) %}
|
||||
{% set grade_data = heatmap_grading_elements.detailed_scores[student_index][element_index] %}
|
||||
<td class="px-1 py-2 text-center text-xs"
|
||||
{% if grade_data %}
|
||||
{% if grade_data.value in heatmap_grading_elements.scale_colors %}
|
||||
style="background-color: {{ heatmap_grading_elements.scale_colors[grade_data.value].color }};
|
||||
color: {% if heatmap_grading_elements.scale_colors[grade_data.value].color in ['#ef4444', '#f97316', '#3b82f6', '#8b5cf6'] %}white{% else %}black{% endif %};"
|
||||
{% elif grade_data.grading_type == 'notes' %}
|
||||
{% set percentage = (grade_data.value|float / grade_data.max_points) * 100 %}
|
||||
style="background-color: {{ '#ef4444' if percentage <= 25 else ('#f97316' if percentage <= 50 else ('#eab308' if percentage <= 75 else '#22c55e')) }};
|
||||
color: {{ 'white' if percentage <= 50 else 'black' }};"
|
||||
{% endif %}
|
||||
{% endif %}>
|
||||
{% if grade_data %}
|
||||
{{ grade_data.value }}
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex justify-center">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Échelle de notation :</div>
|
||||
</div>
|
||||
<div class="flex justify-center flex-wrap gap-4 text-xs">
|
||||
{% for value, config in heatmap_grading_elements.scale_colors.items() %}
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 mr-1" style="background-color: {{ config.color }}"></div>
|
||||
<span>{{ value }}: {{ config.label }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex justify-center mt-2">
|
||||
<span class="text-xs text-gray-600">Notes numériques : dégradé rouge (faible) → vert (élevé)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,20 +331,24 @@
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Résultats par élève</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Cliquez sur les en-têtes pour trier</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200 sortable-table" id="detailsTable">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sortable cursor-pointer hover:bg-gray-100" onclick="sortTable('detailsTable', 0)">
|
||||
Élève
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider sortable cursor-pointer hover:bg-gray-100" onclick="sortTable('detailsTable', 1)">
|
||||
Note totale
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% for exercise in assessment.exercises|sort(attribute='order') %}
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider sortable cursor-pointer hover:bg-gray-100" onclick="sortTable('detailsTable', {{ loop.index + 1 }})">
|
||||
{{ exercise.title }}
|
||||
<span class="sort-arrow ml-1">⇅</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
@@ -149,19 +362,36 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
{% if student_data.total_special_display %}
|
||||
<div class="text-sm font-bold px-2 py-1 rounded text-center inline-block"
|
||||
style="background-color: {{ heatmap_grading_elements.scale_colors[student_data.total_special_display].color }};
|
||||
color: {% if heatmap_grading_elements.scale_colors[student_data.total_special_display].color in ['#ef4444', '#f97316', '#3b82f6', '#8b5cf6'] %}white{% else %}black{% endif %};">
|
||||
{{ student_data.total_special_display }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm font-bold text-gray-900">
|
||||
{{ "%.1f"|format(student_data.total_score) }} / {{ "%.1f"|format(student_data.total_max_points) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for exercise in assessment.exercises|sort(attribute='order') %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="text-sm text-gray-900">
|
||||
{% if exercise.id in student_data.exercises %}
|
||||
{{ "%.1f"|format(student_data.exercises[exercise.id].score) }} / {{ "%.1f"|format(student_data.exercises[exercise.id].max_points) }}
|
||||
{% if exercise.id in student_data.exercises %}
|
||||
{% set exercise_data = student_data.exercises[exercise.id] %}
|
||||
{% if exercise_data.special_display %}
|
||||
<div class="text-sm px-2 py-1 rounded text-center inline-block"
|
||||
style="background-color: {{ heatmap_grading_elements.scale_colors[exercise_data.special_display].color }};
|
||||
color: {% if heatmap_grading_elements.scale_colors[exercise_data.special_display].color in ['#ef4444', '#f97316', '#3b82f6', '#8b5cf6'] %}white{% else %}black{% endif %};">
|
||||
{{ exercise_data.special_display }}
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
<div class="text-sm text-gray-900">
|
||||
{{ "%.1f"|format(exercise_data.score) }} / {{ "%.1f"|format(exercise_data.max_points) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-900">-</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
@@ -290,490 +520,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === HEATMAPS ===
|
||||
|
||||
// === HEATMAPS DATA ===
|
||||
{% if heatmap_competences %}
|
||||
const competencesData = {{ heatmap_competences | tojson }};
|
||||
{% endif %}
|
||||
|
||||
{% if heatmap_domains %}
|
||||
const domainsData = {{ heatmap_domains | tojson }};
|
||||
{% endif %}
|
||||
|
||||
{% if heatmap_grading_elements %}
|
||||
const gradingElementsData = {{ heatmap_grading_elements | tojson }};
|
||||
{% endif %}
|
||||
|
||||
// Fonctions utilitaires pour l'interpolation HSL (globales pour être réutilisables)
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0; // achromatic
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360;
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
let r, g, b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255)
|
||||
};
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return color1;
|
||||
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b);
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
// Interpolation en HSL
|
||||
let h1 = hsl1.h;
|
||||
let h2 = hsl2.h;
|
||||
|
||||
// Gérer la transition des teintes (éviter le long chemin autour du cercle)
|
||||
let deltaH = h2 - h1;
|
||||
if (deltaH > 180) {
|
||||
h2 -= 360;
|
||||
} else if (deltaH < -180) {
|
||||
h2 += 360;
|
||||
}
|
||||
|
||||
const h = h1 + (h2 - h1) * factor;
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor;
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor;
|
||||
|
||||
// Normaliser la teinte
|
||||
const normalizedH = ((h % 360) + 360) % 360;
|
||||
|
||||
const rgb = hslToRgb(normalizedH, s, l);
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||
}
|
||||
|
||||
function createHeatmap(canvasId, data, type) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const students = data.students;
|
||||
const categories = type === 'compétence' ? data.competences :
|
||||
(type === 'domaine' ? data.domains : data.elements);
|
||||
const scores = data.scores;
|
||||
const colors = data.colors;
|
||||
|
||||
if (!categories || categories.length === 0) return;
|
||||
|
||||
// Dimensions adaptées selon le type
|
||||
const padding = type === 'élément' ? 120 : 80; // Plus d'espace pour les libellés longs
|
||||
const minCellWidth = type === 'élément' ? 50 : 60;
|
||||
const minCellHeight = type === 'élément' ? 30 : 40; // Plus compact pour les éléments
|
||||
|
||||
const cellWidth = Math.max(minCellWidth, (canvas.width - padding * 2) / students.length);
|
||||
const cellHeight = Math.max(minCellHeight, (canvas.height - padding * 2) / categories.length);
|
||||
|
||||
// Ajuster la taille du canvas si nécessaire
|
||||
canvas.width = cellWidth * students.length + padding * 2;
|
||||
canvas.height = cellHeight * categories.length + padding * 2;
|
||||
|
||||
// Effacer le canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = '12px Arial';
|
||||
|
||||
// Fonction pour convertir un score en couleur avec dégradé continu HSL
|
||||
function getScoreColor(score) {
|
||||
if (score === null || score === undefined) {
|
||||
return '#f3f4f6'; // Gris clair pour les données manquantes
|
||||
}
|
||||
|
||||
// Dégradé continu du rouge (0%) au vert (100%) en utilisant l'interpolation HSL
|
||||
const percentage = Math.max(0, Math.min(100, score));
|
||||
const factor = percentage / 100;
|
||||
|
||||
// Couleurs de début et fin (rouge vers vert)
|
||||
const redColor = '#ef4444'; // Rouge (même que dans la config)
|
||||
const greenColor = '#22c55e'; // Vert (même que dans la config)
|
||||
|
||||
return interpolateColorHSL(redColor, greenColor, factor);
|
||||
}
|
||||
|
||||
// Dessiner les cellules
|
||||
for (let studentIndex = 0; studentIndex < students.length; studentIndex++) {
|
||||
for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex++) {
|
||||
const score = scores[studentIndex][categoryIndex];
|
||||
const x = padding + studentIndex * cellWidth;
|
||||
const y = padding + categoryIndex * cellHeight;
|
||||
|
||||
// Couleur de fond basée sur le score
|
||||
ctx.fillStyle = getScoreColor(score);
|
||||
ctx.fillRect(x, y, cellWidth - 1, cellHeight - 1);
|
||||
|
||||
// Bordure
|
||||
ctx.strokeStyle = '#d1d5db';
|
||||
ctx.strokeRect(x, y, cellWidth - 1, cellHeight - 1);
|
||||
|
||||
// Texte du score
|
||||
ctx.font = type === 'élément' ? '10px Arial' : '12px Arial'; // Police adaptée
|
||||
if (score !== null && score !== undefined) {
|
||||
ctx.fillStyle = score > 60 ? '#ffffff' : '#000000'; // Blanc sur foncé, noir sur clair
|
||||
ctx.textAlign = 'center';
|
||||
// Affichage simplifié pour les éléments (sans %)
|
||||
const displayText = type === 'élément' ? score.toFixed(0) : score.toFixed(1) + '%';
|
||||
ctx.fillText(displayText, x + cellWidth/2, y + cellHeight/2 + 4);
|
||||
} else {
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('-', x + cellWidth/2, y + cellHeight/2 + 4);
|
||||
}
|
||||
|
||||
// Remettre la police normale après
|
||||
ctx.font = '12px Arial';
|
||||
}
|
||||
}
|
||||
|
||||
// Labels des étudiants (vertical, en haut)
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.textAlign = 'left';
|
||||
for (let i = 0; i < students.length; i++) {
|
||||
const x = padding + i * cellWidth + cellWidth/2;
|
||||
const y = padding - 10;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(-Math.PI/4); // Rotation de -45 degrés
|
||||
ctx.fillText(students[i], 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Labels des catégories (à gauche)
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = type === 'élément' ? '10px Arial' : '12px Arial'; // Police plus petite pour les éléments
|
||||
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
const x = padding - 10;
|
||||
const y = padding + i * cellHeight + cellHeight/2 + 4;
|
||||
|
||||
// Utiliser la couleur de la catégorie si disponible
|
||||
const category = categories[i];
|
||||
ctx.fillStyle = colors[category] || '#374151';
|
||||
|
||||
// Tronquer le texte si trop long pour les éléments
|
||||
let displayText = category;
|
||||
if (type === 'élément' && category.length > 25) {
|
||||
displayText = category.substring(0, 22) + '...';
|
||||
}
|
||||
|
||||
ctx.fillText(displayText, x, y);
|
||||
}
|
||||
|
||||
// Légende (échelle de couleur continue)
|
||||
const legendY = canvas.height - 40;
|
||||
const legendHeight = 20;
|
||||
const legendWidth = 200;
|
||||
const legendX = (canvas.width - legendWidth) / 2;
|
||||
|
||||
// Dessiner la barre de légende avec dégradé continu
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const x = legendX + (i / 100) * legendWidth;
|
||||
ctx.fillStyle = getScoreColor(i);
|
||||
ctx.fillRect(x, legendY, 2, legendHeight);
|
||||
}
|
||||
|
||||
// Bordure de la légende
|
||||
ctx.strokeStyle = '#d1d5db';
|
||||
ctx.strokeRect(legendX, legendY, legendWidth, legendHeight);
|
||||
|
||||
// Labels de la légende
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('0%', legendX, legendY + legendHeight + 15);
|
||||
ctx.fillText('50%', legendX + legendWidth/2, legendY + legendHeight + 15);
|
||||
ctx.fillText('100%', legendX + legendWidth, legendY + legendHeight + 15);
|
||||
|
||||
// Titre de la légende
|
||||
ctx.fillText('Score moyen (%)', legendX + legendWidth/2, legendY - 10);
|
||||
}
|
||||
|
||||
function createGradingElementsHeatmap(canvasId, data) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const students = data.students;
|
||||
const elements = data.elements;
|
||||
const detailedScores = data.detailed_scores;
|
||||
const colors = data.colors;
|
||||
const scaleColors = data.scale_colors;
|
||||
|
||||
if (!elements || elements.length === 0) return;
|
||||
|
||||
// Dimensions adaptées pour les éléments
|
||||
const padding = 120;
|
||||
const minCellWidth = 50;
|
||||
const minCellHeight = 30;
|
||||
|
||||
const cellWidth = Math.max(minCellWidth, (canvas.width - padding * 2) / students.length);
|
||||
const cellHeight = Math.max(minCellHeight, (canvas.height - padding * 2) / elements.length);
|
||||
|
||||
// Ajuster la taille du canvas si nécessaire
|
||||
canvas.width = cellWidth * students.length + padding * 2;
|
||||
canvas.height = cellHeight * elements.length + padding * 2;
|
||||
|
||||
// Effacer le canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = '10px Arial';
|
||||
|
||||
// Fonction pour obtenir la couleur selon le type et la valeur
|
||||
function getGradeColor(gradeData) {
|
||||
if (!gradeData) {
|
||||
return '#f3f4f6'; // Gris clair pour les données manquantes
|
||||
}
|
||||
|
||||
const { value, grading_type, max_points } = gradeData;
|
||||
|
||||
// Pour les valeurs spéciales définies en base (., d, a, etc.)
|
||||
if (scaleColors[value]) {
|
||||
return scaleColors[value].color;
|
||||
}
|
||||
|
||||
if (grading_type === 'score') {
|
||||
// Pour les scores (0-3), utiliser directement les couleurs de base
|
||||
if (scaleColors[value]) {
|
||||
return scaleColors[value].color;
|
||||
}
|
||||
// Fallback si la valeur n'existe pas dans l'échelle
|
||||
return '#6b7280';
|
||||
} else if (grading_type === 'notes') {
|
||||
// Pour les notes numériques, calculer le pourcentage et utiliser un dégradé
|
||||
try {
|
||||
const numericValue = parseFloat(value.toString().replace(',', '.'));
|
||||
const percentage = (numericValue / max_points) * 100;
|
||||
|
||||
// Utiliser le même dégradé HSL que les autres heatmaps pour les notes
|
||||
const factor = Math.max(0, Math.min(100, percentage)) / 100;
|
||||
return interpolateColorHSL('#ef4444', '#22c55e', factor);
|
||||
} catch (e) {
|
||||
return '#6b7280'; // Gris si erreur de parsing
|
||||
}
|
||||
}
|
||||
|
||||
return '#6b7280'; // Couleur par défaut
|
||||
}
|
||||
|
||||
// Dessiner les cellules
|
||||
for (let studentIndex = 0; studentIndex < students.length; studentIndex++) {
|
||||
for (let elementIndex = 0; elementIndex < elements.length; elementIndex++) {
|
||||
const gradeData = detailedScores[studentIndex][elementIndex];
|
||||
const x = padding + studentIndex * cellWidth;
|
||||
const y = padding + elementIndex * cellHeight;
|
||||
|
||||
// Couleur de fond basée sur la vraie valeur et le type
|
||||
ctx.fillStyle = getGradeColor(gradeData);
|
||||
ctx.fillRect(x, y, cellWidth - 1, cellHeight - 1);
|
||||
|
||||
// Bordure
|
||||
ctx.strokeStyle = '#d1d5db';
|
||||
ctx.strokeRect(x, y, cellWidth - 1, cellHeight - 1);
|
||||
|
||||
// Texte de la valeur originale
|
||||
ctx.font = '10px Arial';
|
||||
if (gradeData) {
|
||||
// Déterminer la couleur du texte selon la luminosité du fond
|
||||
const bgColor = getGradeColor(gradeData);
|
||||
const isLightBackground = isColorLight(bgColor);
|
||||
ctx.fillStyle = isLightBackground ? '#000000' : '#ffffff';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// Afficher la valeur originale (pas de conversion en %)
|
||||
ctx.fillText(gradeData.value.toString(), x + cellWidth/2, y + cellHeight/2 + 4);
|
||||
} else {
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('-', x + cellWidth/2, y + cellHeight/2 + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labels des étudiants (vertical, en haut)
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '12px Arial';
|
||||
for (let i = 0; i < students.length; i++) {
|
||||
const x = padding + i * cellWidth + cellWidth/2;
|
||||
const y = padding - 10;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(-Math.PI/4);
|
||||
ctx.fillText(students[i], 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Labels des éléments (à gauche)
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = '10px Arial';
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const x = padding - 10;
|
||||
const y = padding + i * cellHeight + cellHeight/2 + 4;
|
||||
|
||||
// Utiliser la couleur de l'élément si disponible
|
||||
const element = elements[i];
|
||||
ctx.fillStyle = colors[element] || '#374151';
|
||||
|
||||
// Tronquer le texte si trop long
|
||||
let displayText = element;
|
||||
if (element.length > 25) {
|
||||
displayText = element.substring(0, 22) + '...';
|
||||
}
|
||||
|
||||
ctx.fillText(displayText, x, y);
|
||||
}
|
||||
|
||||
// Légende spéciale pour les éléments de notation
|
||||
drawGradingElementsLegend(ctx, canvas, scaleColors);
|
||||
}
|
||||
|
||||
// Fonction pour déterminer si une couleur est claire ou foncée
|
||||
function isColorLight(hexColor) {
|
||||
const rgb = hexToRgb(hexColor);
|
||||
if (!rgb) return true;
|
||||
|
||||
// Calcul de la luminance relative
|
||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||
return luminance > 0.5;
|
||||
}
|
||||
|
||||
// Fonction pour dessiner la légende spécialisée
|
||||
function drawGradingElementsLegend(ctx, canvas, scaleColors) {
|
||||
const legendY = canvas.height - 80;
|
||||
const legendItemHeight = 15;
|
||||
const legendItemWidth = 15;
|
||||
const legendSpacing = 5;
|
||||
|
||||
ctx.font = '11px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// Titre de la légende
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.fillText('Échelle de notation :', 20, legendY - 10);
|
||||
|
||||
let currentX = 20;
|
||||
let currentY = legendY;
|
||||
|
||||
// Afficher les valeurs de l'échelle configurée
|
||||
const sortedScaleItems = Object.entries(scaleColors).sort((a, b) => {
|
||||
// Trier les valeurs numériques avant les valeurs spéciales
|
||||
const aIsNum = !isNaN(parseInt(a[0]));
|
||||
const bIsNum = !isNaN(parseInt(b[0]));
|
||||
|
||||
if (aIsNum && bIsNum) return parseInt(a[0]) - parseInt(b[0]);
|
||||
if (aIsNum && !bIsNum) return -1;
|
||||
if (!aIsNum && bIsNum) return 1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
for (const [value, config] of sortedScaleItems) {
|
||||
// Rectangle de couleur
|
||||
ctx.fillStyle = config.color;
|
||||
ctx.fillRect(currentX, currentY, legendItemWidth, legendItemHeight);
|
||||
|
||||
// Bordure
|
||||
ctx.strokeStyle = '#d1d5db';
|
||||
ctx.strokeRect(currentX, currentY, legendItemWidth, legendItemHeight);
|
||||
|
||||
// Texte
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.fillText(`${value}: ${config.label}`, currentX + legendItemWidth + legendSpacing, currentY + legendItemHeight - 3);
|
||||
|
||||
// Passer à la ligne après quelques éléments pour éviter le débordement
|
||||
currentX += 120;
|
||||
if (currentX > canvas.width - 150) {
|
||||
currentX = 20;
|
||||
currentY += legendItemHeight + legendSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
// Note explicative pour les notes numériques
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.fillText('Notes numériques : dégradé rouge (0%) → vert (100%)', 20, currentY + 25);
|
||||
}
|
||||
|
||||
// === APPELS AUX HEATMAPS (après toutes les définitions de fonctions) ===
|
||||
|
||||
{% if heatmap_competences %}
|
||||
// Heatmap des compétences
|
||||
createHeatmap('competencesHeatmap', competencesData, 'compétence');
|
||||
{% endif %}
|
||||
|
||||
{% if heatmap_domains %}
|
||||
// Heatmap des domaines
|
||||
createHeatmap('domainsHeatmap', domainsData, 'domaine');
|
||||
{% endif %}
|
||||
|
||||
{% if heatmap_grading_elements %}
|
||||
// Heatmap des éléments de notation avec couleurs de base
|
||||
createGradingElementsHeatmap('gradingElementsHeatmap', gradingElementsData);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
// === FONCTIONNALITÉ D'ENVOI DE BILANS ===
|
||||
@@ -985,6 +731,95 @@ document.addEventListener('click', function(e) {
|
||||
closeSendReportsModal();
|
||||
}
|
||||
});
|
||||
|
||||
// === FONCTIONNALITÉ DE TRI DES TABLEAUX ===
|
||||
|
||||
function sortTable(tableId, columnIndex) {
|
||||
const table = document.getElementById(tableId);
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.rows);
|
||||
const isNumeric = checkIfNumericColumn(rows, columnIndex);
|
||||
|
||||
// Déterminer la direction du tri
|
||||
const currentDirection = table.dataset.sortDirection || 'none';
|
||||
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
|
||||
table.dataset.sortDirection = newDirection;
|
||||
table.dataset.sortColumn = columnIndex;
|
||||
|
||||
// Tri des lignes
|
||||
rows.sort((a, b) => {
|
||||
let aVal = getCellValue(a, columnIndex);
|
||||
let bVal = getCellValue(b, columnIndex);
|
||||
|
||||
if (isNumeric) {
|
||||
aVal = parseFloat(aVal) || 0;
|
||||
bVal = parseFloat(bVal) || 0;
|
||||
} else {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return newDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return newDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Réorganiser les lignes dans le tableau
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Mettre à jour les indicateurs visuels
|
||||
updateSortIndicators(table, columnIndex, newDirection);
|
||||
}
|
||||
|
||||
function getCellValue(row, columnIndex) {
|
||||
const cell = row.cells[columnIndex];
|
||||
if (!cell) return '';
|
||||
|
||||
// Pour les cellules avec des couleurs de fond (valeurs spéciales), extraire le texte
|
||||
let text = cell.textContent.trim();
|
||||
|
||||
// Nettoyer les valeurs numériques (enlever %, /X, etc.)
|
||||
text = text.replace(/[%\/]/g, '').split(/[\s\/]/)[0];
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function checkIfNumericColumn(rows, columnIndex) {
|
||||
if (rows.length === 0) return false;
|
||||
|
||||
// Vérifier les 5 premières lignes pour déterminer si c'est numérique
|
||||
const sampleSize = Math.min(5, rows.length);
|
||||
let numericCount = 0;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const value = getCellValue(rows[i], columnIndex);
|
||||
if (value && !isNaN(parseFloat(value))) {
|
||||
numericCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return numericCount >= sampleSize / 2;
|
||||
}
|
||||
|
||||
function updateSortIndicators(table, columnIndex, direction) {
|
||||
// Réinitialiser tous les indicateurs
|
||||
const headers = table.querySelectorAll('th.sortable');
|
||||
headers.forEach(header => {
|
||||
const arrow = header.querySelector('.sort-arrow');
|
||||
if (arrow) {
|
||||
arrow.textContent = '⇅';
|
||||
}
|
||||
});
|
||||
|
||||
// Mettre à jour l'indicateur de la colonne triée
|
||||
const currentHeader = headers[columnIndex];
|
||||
if (currentHeader) {
|
||||
const arrow = currentHeader.querySelector('.sort-arrow');
|
||||
if (arrow) {
|
||||
arrow.textContent = direction === 'asc' ? '▲' : '▼';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modal d'envoi des bilans -->
|
||||
|
||||
Reference in New Issue
Block a user