feat: add heatmap to resultat
This commit is contained in:
380
docs/frontend/ASSESSMENT_RESULTS_PAGE.md
Normal file
380
docs/frontend/ASSESSMENT_RESULTS_PAGE.md
Normal file
@@ -0,0 +1,380 @@
|
||||
# 📊 Page des Résultats d'Évaluation
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
La **page des résultats d'évaluation** (`/assessments/<id>/results`) est une interface d'analyse complète qui présente les performances des élèves sous forme de statistiques, visualisations et heatmaps interactives. Cette page constitue l'aboutissement du processus d'évaluation et offre aux enseignants une vision claire et détaillée des résultats.
|
||||
|
||||
## 🎯 Fonctionnalités Principales
|
||||
|
||||
### 1. **Statistiques Descriptives**
|
||||
- **Nombre d'élèves** évalués
|
||||
- **Moyenne générale** de la classe
|
||||
- **Médiane** des scores
|
||||
- **Minimum et Maximum** obtenus
|
||||
- **Écart-type** de la distribution
|
||||
- **Total des points** possible pour l'évaluation
|
||||
|
||||
### 2. **Histogramme de Distribution**
|
||||
- **Graphique en barres** des notes avec bins de 1 point
|
||||
- **Tooltips personnalisés** affichant la liste des élèves pour chaque barre
|
||||
- **Visualisation Chart.js** responsive et interactive
|
||||
- **Animation fluide** et design cohérent
|
||||
|
||||
### 3. **Heatmaps d'Analyse**
|
||||
- **Heatmap par compétences** : Performance des élèves par compétence évaluée
|
||||
- **Heatmap par domaines** : Performance des élèves par domaine mathématique
|
||||
- **Heatmap par éléments de notation** : Vue détaillée question par question avec couleurs authentiques de la base de données
|
||||
|
||||
### 4. **Tableau Détaillé des Résultats**
|
||||
- **Liste alphabétique** des élèves avec leurs scores
|
||||
- **Détail par exercice** au format "score/total"
|
||||
- **Scores totaux** précis avec décimales
|
||||
- **Navigation intuitive** vers les détails
|
||||
|
||||
## 🏗️ Architecture Technique
|
||||
|
||||
### Backend - Route `/assessments/<id>/results`
|
||||
|
||||
#### Fichier : `routes/assessments.py` (lignes 187-429)
|
||||
|
||||
```python
|
||||
@bp.route('/<int:id>/results')
|
||||
@handle_db_errors
|
||||
def results(id):
|
||||
# 1. Récupération de l'évaluation avec tous ses détails
|
||||
assessment_repo = AssessmentRepository()
|
||||
assessment = assessment_repo.get_with_full_details_or_404(id)
|
||||
|
||||
# 2. Calculs des scores et statistiques
|
||||
students_scores, exercise_scores = assessment.calculate_student_scores()
|
||||
statistics = assessment.get_assessment_statistics()
|
||||
|
||||
# 3. Préparation des données heatmaps
|
||||
# - Collecte des compétences et domaines avec couleurs depuis la BD
|
||||
# - Calcul des scores agrégés par compétence/domaine
|
||||
# - Préparation des données pour éléments de notation
|
||||
|
||||
# 4. Rendu du template avec toutes les données
|
||||
return render_template('assessment_results.html', ...)
|
||||
```
|
||||
|
||||
#### Logique de Préparation des Heatmaps
|
||||
|
||||
**Compétences et Domaines :**
|
||||
```python
|
||||
# Récupération des couleurs depuis la base de données
|
||||
all_competences = {comp.name: comp.color for comp in Competence.query.all()}
|
||||
all_domains = {domain.id: {'name': domain.name, 'color': domain.color}
|
||||
for domain in Domain.query.all()}
|
||||
|
||||
# Calcul des pourcentages par élève/compétence
|
||||
for student_data in sorted_students:
|
||||
for comp in competences_list:
|
||||
if comp in student_scores_by_competence:
|
||||
percentage = (student_scores_by_competence[comp] /
|
||||
student_totals_by_competence[comp]) * 100
|
||||
student_row.append(round(percentage, 1))
|
||||
```
|
||||
|
||||
**Éléments de Notation :**
|
||||
```python
|
||||
# Couleurs par exercice pour meilleure distinction visuelle
|
||||
exercise_colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', ...]
|
||||
|
||||
# Données détaillées avec valeurs originales
|
||||
for element_key in grading_elements_list:
|
||||
student_row.append({
|
||||
'value': grade.value,
|
||||
'grading_type': element.grading_type,
|
||||
'max_points': element.max_points
|
||||
})
|
||||
```
|
||||
|
||||
### Frontend - Template `assessment_results.html`
|
||||
|
||||
#### Structure HTML
|
||||
|
||||
**Statistiques Générales :**
|
||||
```html
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ statistics.count }}</div>
|
||||
<div class="text-sm text-gray-500">Élèves</div>
|
||||
</div>
|
||||
<!-- Autres métriques... -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Histogramme Interactif :**
|
||||
```html
|
||||
<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">Distribution des notes</h2>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<canvas id="scoresChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### JavaScript Chart.js avec Tooltips Personnalisés
|
||||
|
||||
**Données des Élèves :**
|
||||
```javascript
|
||||
const studentsData = [
|
||||
{% for student_data in students_scores %}
|
||||
{
|
||||
name: "{{ student_data.student.last_name }} {{ student_data.student.first_name }}",
|
||||
score: {{ student_data.total_score }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
```
|
||||
|
||||
**Configuration Chart.js :**
|
||||
```javascript
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Nombre d\'élèves',
|
||||
data: bins,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.5)',
|
||||
borderColor: 'rgba(59, 130, 246, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(tooltipItem) {
|
||||
const binIndex = tooltipItem.dataIndex;
|
||||
const count = bins[binIndex];
|
||||
const students = studentsInBins[binIndex];
|
||||
|
||||
if (count === 1) {
|
||||
return '1 élève : ' + students[0];
|
||||
} else {
|
||||
return `${count} élèves : ${students.join(', ')}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
maxWidth: 400
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Heatmaps Canvas Personnalisées
|
||||
|
||||
#### Fonction `createHeatmap()` pour Compétences/Domaines
|
||||
```javascript
|
||||
function createHeatmap(canvasId, data, type) {
|
||||
// 1. Configuration des dimensions adaptatives
|
||||
const cellWidth = Math.max(minCellWidth, (canvas.width - padding * 2) / students.length);
|
||||
const cellHeight = Math.max(minCellHeight, (canvas.height - padding * 2) / categories.length);
|
||||
|
||||
// 2. Fonction de couleur avec dégradé HSL continu
|
||||
function getScoreColor(score) {
|
||||
const factor = percentage / 100;
|
||||
return interpolateColorHSL('#ef4444', '#22c55e', factor); // Rouge → Vert
|
||||
}
|
||||
|
||||
// 3. Rendu des cellules avec labels rotatifs
|
||||
// 4. Légende avec échelle continue 0% → 100%
|
||||
}
|
||||
```
|
||||
|
||||
#### Fonction `createGradingElementsHeatmap()` pour Éléments
|
||||
```javascript
|
||||
function createGradingElementsHeatmap(canvasId, data) {
|
||||
// 1. Récupération des couleurs authentiques depuis la BD
|
||||
const scaleColors = data.scale_colors;
|
||||
|
||||
// 2. Fonction de couleur selon le type
|
||||
function getGradeColor(gradeData) {
|
||||
if (gradeData.grading_type === 'score') {
|
||||
// Utiliser les couleurs de l'échelle configurée
|
||||
return scaleColors[gradeData.value].color;
|
||||
} else if (gradeData.grading_type === 'notes') {
|
||||
// Dégradé HSL basé sur le pourcentage
|
||||
const percentage = (parseFloat(gradeData.value) / gradeData.max_points) * 100;
|
||||
return interpolateColorHSL('#ef4444', '#22c55e', percentage / 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Labels colorés par exercice pour distinction visuelle
|
||||
// 4. Légende spécialisée avec échelle complète
|
||||
}
|
||||
```
|
||||
|
||||
#### Interpolation HSL pour Dégradés Continus
|
||||
```javascript
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b);
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
// Interpolation en HSL avec gestion des transitions de teinte
|
||||
let deltaH = hsl2.h - hsl1.h;
|
||||
if (deltaH > 180) hsl2.h -= 360;
|
||||
else if (deltaH < -180) hsl2.h += 360;
|
||||
|
||||
const h = hsl1.h + (hsl2.h - hsl1.h) * factor;
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor;
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor;
|
||||
|
||||
return rgbToHex(...hslToRgb(normalizedH, s, l));
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Interface Utilisateur
|
||||
|
||||
### Design System Cohérent
|
||||
- **Couleurs** : Palette Tailwind avec bleus, verts, oranges
|
||||
- **Typographie** : Hiérarchie claire avec font-bold et font-medium
|
||||
- **Espacement** : Grid system responsive avec gap-4/6
|
||||
- **Cartes** : `bg-white shadow rounded-lg` pour la consistance
|
||||
|
||||
### Responsive Design
|
||||
- **Mobile** : Scrolling horizontal pour les heatmaps
|
||||
- **Tablet** : Grilles adaptatives 2-3 colonnes
|
||||
- **Desktop** : Grilles 6 colonnes pour les statistiques
|
||||
|
||||
### Interactions
|
||||
- **Tooltips Chart.js** : Liste des élèves par tranche de notes
|
||||
- **Heatmaps hover** : Affichage des scores détaillés
|
||||
- **Navigation** : Lien retour vers l'évaluation
|
||||
|
||||
## 📊 Types de Données Affichées
|
||||
|
||||
### 1. **Scores Calculés**
|
||||
- **Notes numériques** : Sommation directe des valeurs décimales
|
||||
- **Scores de compétences** : Formule `score * max_points / 3`
|
||||
- **Valeurs spéciales** : `.` = 0, `d` = dispensé, `a` = absent
|
||||
|
||||
### 2. **Agrégations**
|
||||
- **Par compétence** : Moyenne pondérée des éléments associés
|
||||
- **Par domaine** : Moyenne pondérée des éléments du domaine
|
||||
- **Par exercice** : Somme des éléments de l'exercice
|
||||
|
||||
### 3. **Métadonnées**
|
||||
- **Couleurs authentiques** : Récupérées depuis les modèles BD
|
||||
- **Libellés configurables** : Depuis l'échelle de notation
|
||||
- **Statuts de correction** : Basés sur la saisie des notes
|
||||
|
||||
## 🔧 Gestion des Cas Particuliers
|
||||
|
||||
### Données Manquantes
|
||||
```javascript
|
||||
if (score === null || score === undefined) {
|
||||
return '#f3f4f6'; // Gris clair
|
||||
}
|
||||
```
|
||||
|
||||
### Valeurs Spéciales
|
||||
```python
|
||||
# Configuration depuis la base de données
|
||||
scale_colors = config_manager.get_competence_scale_values()
|
||||
if scaleColors[value]:
|
||||
return scaleColors[value].color
|
||||
```
|
||||
|
||||
### Calculs de Pourcentage
|
||||
```python
|
||||
if student_totals_by_competence[comp] > 0:
|
||||
percentage = (student_scores_by_competence[comp] /
|
||||
student_totals_by_competence[comp]) * 100
|
||||
student_row.append(round(percentage, 1))
|
||||
```
|
||||
|
||||
## 🚀 Performance et Optimisations
|
||||
|
||||
### Backend
|
||||
- **Requêtes optimisées** : `get_with_full_details_or_404()` avec jointures
|
||||
- **Calculs unifiés** : Services découplés avec injection de dépendances
|
||||
- **Cache des couleurs** : Récupération unique depuis la BD
|
||||
|
||||
### Frontend
|
||||
- **Canvas natif** : Rendu des heatmaps sans librairies lourdes
|
||||
- **Chart.js CDN** : Chargement optimisé des graphiques
|
||||
- **Responsive dimensions** : Calculs adaptatifs des tailles
|
||||
|
||||
### JavaScript
|
||||
- **Fonctions globales** : Réutilisation des utilitaires HSL
|
||||
- **Gestion d'erreurs** : Fallbacks pour données manquantes
|
||||
- **Optimisation DOM** : Mise à jour ciblée des éléments
|
||||
|
||||
## 🎯 Cas d'Usage Typiques
|
||||
|
||||
### 1. **Analyse de Classe**
|
||||
L'enseignant consulte la page après correction complète pour :
|
||||
- **Identifier les difficultés** : Compétences en rouge sur les heatmaps
|
||||
- **Repérer les réussites** : Domaines bien maîtrisés en vert
|
||||
- **Analyser la distribution** : Homogénéité/hétérogénéité de la classe
|
||||
|
||||
### 2. **Feedback Individuel**
|
||||
Utilisation du tableau détaillé pour :
|
||||
- **Scores par exercice** : Performance question par question
|
||||
- **Comparaison relative** : Position dans la distribution
|
||||
- **Identification des lacunes** : Éléments non maîtrisés
|
||||
|
||||
### 3. **Reporting Pédagogique**
|
||||
Les statistiques servent à :
|
||||
- **Évaluer la difficulté** : Moyenne générale vs objectifs
|
||||
- **Adapter la pédagogie** : Révisions ciblées sur les compétences faibles
|
||||
- **Communiquer avec les familles** : Résultats contextualisés
|
||||
|
||||
## 🔄 Évolutions Récentes (2025)
|
||||
|
||||
### ✅ **Améliorations Implementées**
|
||||
- **Tooltips personnalisés** dans l'histogramme avec liste des élèves
|
||||
- **Heatmap des éléments de notation** avec couleurs authentiques de la BD
|
||||
- **Distinction visuelle par exercice** avec palette de couleurs dédiée
|
||||
- **Interpolation HSL** pour dégradés continus sans sauts de couleur
|
||||
- **Gestion des erreurs JavaScript** avec fonctions globales structurées
|
||||
|
||||
### 🎨 **Améliorations UX**
|
||||
- **Maxwidth tooltips** : Affichage optimal sur 400px
|
||||
- **Police adaptative** : 10px pour les éléments, 12px pour les heatmaps classiques
|
||||
- **Troncature intelligente** : Libellés longs coupés à 22 caractères
|
||||
- **Légende complète** : Échelle de notation avec explications
|
||||
|
||||
## 🔍 Tests et Validation
|
||||
|
||||
### Tests d'Intégration
|
||||
```bash
|
||||
# Test de la route des résultats
|
||||
uv run pytest tests/test_routes_assessments.py -k "result"
|
||||
|
||||
# Validation des calculs
|
||||
uv run pytest tests/test_models.py -k "calculate_student_scores"
|
||||
```
|
||||
|
||||
### Tests Frontend
|
||||
- **Vérification Chart.js** : Présence des tooltips avec noms d'élèves
|
||||
- **Validation heatmaps** : Rendu correct des couleurs authentiques
|
||||
- **Test responsive** : Affichage sur différentes tailles d'écran
|
||||
|
||||
## 🔗 Navigation et Intégration
|
||||
|
||||
### Points d'Entrée
|
||||
- **Dashboard évaluation** : Bouton "Résultats" sur `/assessments/<id>`
|
||||
- **Liste des évaluations** : Lien direct depuis la progression
|
||||
- **Navigation breadcrumb** : Retour vers l'évaluation parente
|
||||
|
||||
### Données Partagées
|
||||
- **Statistiques** : Réutilisées dans d'autres vues analytiques
|
||||
- **Scores calculés** : Cohérents avec les autres pages de l'application
|
||||
- **Configuration** : Respect de l'échelle de notation globale
|
||||
|
||||
---
|
||||
|
||||
**La page des résultats d'évaluation représente l'aboutissement de l'écosystème Notytex, offrant une analyse complète et visuelle des performances avec des outils de diagnostic pédagogique avancés.** 🎓📊
|
||||
@@ -26,6 +26,7 @@ Cette documentation couvre l'ensemble du **design system Notytex**, ses composan
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------- | ------ |
|
||||
| **[CLASSES_PAGE.md](./CLASSES_PAGE.md)** | Page des classes modernisée - Architecture & guide d'utilisation | ✅ |
|
||||
| **[CLASS_FORM.md](./CLASS_FORM.md)** | Formulaire création/modification classes - Interface & UX | ✅ |
|
||||
| **[ASSESSMENT_RESULTS_PAGE.md](./ASSESSMENT_RESULTS_PAGE.md)** | **Page résultats évaluation - Heatmaps & analyses avancées** | ✅ |
|
||||
| [ASSESSMENTS_FILTRES.md](./ASSESSMENTS_FILTRES.md) | Système de filtres des évaluations | ✅ |
|
||||
| Dashboard Modernization | Page d'accueil & statistiques | 📋 |
|
||||
| Student Management Page | Interface de gestion des élèves | 📋 |
|
||||
@@ -118,6 +119,7 @@ xl: 1280px // Large desktop
|
||||
- Guide des bonnes pratiques générales
|
||||
- Page des classes (refonte complète)
|
||||
- **Formulaire de classe (création/modification complet)**
|
||||
- **Page des résultats d'évaluation (heatmaps & analyses)**
|
||||
- Composant class_card (documentation technique)
|
||||
- Filtres des évaluations
|
||||
- Cartes d'évaluation
|
||||
|
||||
@@ -187,6 +187,7 @@ def new():
|
||||
@bp.route('/<int:id>/results')
|
||||
@handle_db_errors
|
||||
def results(id):
|
||||
from models import Competence, Domain
|
||||
assessment_repo = AssessmentRepository()
|
||||
assessment = assessment_repo.get_with_full_details_or_404(id)
|
||||
|
||||
@@ -204,12 +205,228 @@ def results(id):
|
||||
# Préparer les données pour l'histogramme
|
||||
scores = [data['total_score'] for data in students_scores.values()]
|
||||
|
||||
# === NOUVEAUX : Préparer les données pour les heatmaps ===
|
||||
|
||||
# Récupérer toutes les compétences et domaines avec leurs couleurs depuis la BD
|
||||
all_competences = {comp.name: comp.color for comp in Competence.query.all()}
|
||||
all_domains = {domain.id: {'name': domain.name, 'color': domain.color} for domain in Domain.query.all()}
|
||||
|
||||
# Collecter toutes les compétences et domaines présents dans cette évaluation
|
||||
competences_in_eval = set()
|
||||
domains_in_eval = set()
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
if element.skill:
|
||||
competences_in_eval.add(element.skill)
|
||||
if element.domain_id:
|
||||
domains_in_eval.add(element.domain_id)
|
||||
|
||||
# Préparer les données heatmap compétences
|
||||
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
|
||||
competences_scores_matrix = []
|
||||
for student_data in sorted_students:
|
||||
student_scores_by_competence = {}
|
||||
student_totals_by_competence = {}
|
||||
|
||||
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
|
||||
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
|
||||
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:
|
||||
student_scores_by_competence[element.skill] = 0
|
||||
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 = []
|
||||
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))
|
||||
else:
|
||||
student_row.append(None) # Pas de données pour cette compétence
|
||||
|
||||
competences_scores_matrix.append(student_row)
|
||||
|
||||
# Préparer les données heatmap domaines
|
||||
domains_list = []
|
||||
domains_colors = {}
|
||||
# Trier les domain_id (entiers) puis récupérer les noms
|
||||
sorted_domain_ids = sorted(domains_in_eval)
|
||||
for domain_id in sorted_domain_ids:
|
||||
if domain_id in all_domains:
|
||||
domain_name = all_domains[domain_id]['name']
|
||||
domains_list.append(domain_name)
|
||||
domains_colors[domain_name] = all_domains[domain_id]['color']
|
||||
|
||||
# Calculer les scores par élève/domaine
|
||||
domains_scores_matrix = []
|
||||
for student_data in sorted_students:
|
||||
student_scores_by_domain = {}
|
||||
student_totals_by_domain = {}
|
||||
|
||||
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
|
||||
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:
|
||||
student_scores_by_domain[domain_name] = 0
|
||||
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 = []
|
||||
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))
|
||||
else:
|
||||
student_row.append(None) # Pas de données pour ce domaine
|
||||
|
||||
domains_scores_matrix.append(student_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}
|
||||
|
||||
heatmap_competences = {
|
||||
'students': students_list,
|
||||
'competences': competences_list,
|
||||
'scores': competences_scores_matrix,
|
||||
'colors': competences_colors
|
||||
} if competences_list else None
|
||||
|
||||
heatmap_domains = {
|
||||
'students': students_list,
|
||||
'domains': domains_list,
|
||||
'scores': domains_scores_matrix,
|
||||
'colors': domains_colors
|
||||
} if domains_list else None
|
||||
|
||||
# === NOUVEAU : Préparer les données heatmap éléments de notation ===
|
||||
|
||||
# Collecter tous les éléments de notation de l'évaluation
|
||||
grading_elements_list = []
|
||||
grading_elements_info = {}
|
||||
|
||||
for exercise in sorted(assessment.exercises, key=lambda x: x.order):
|
||||
for element in exercise.grading_elements:
|
||||
element_key = f"{exercise.title} - {element.label}"
|
||||
grading_elements_list.append(element_key)
|
||||
grading_elements_info[element_key] = {
|
||||
'element': element,
|
||||
'exercise': exercise
|
||||
}
|
||||
|
||||
# Collecter les données détaillées par élève/élément avec valeurs originales
|
||||
grading_elements_detailed_matrix = []
|
||||
for student_data in sorted_students:
|
||||
student_row = []
|
||||
|
||||
for element_key in grading_elements_list:
|
||||
element_info = grading_elements_info[element_key]
|
||||
element = element_info['element']
|
||||
|
||||
# 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:
|
||||
# Stocker les données complètes pour le rendu avec couleurs spécifiques
|
||||
student_row.append({
|
||||
'value': grade.value,
|
||||
'grading_type': element.grading_type,
|
||||
'max_points': element.max_points
|
||||
})
|
||||
else:
|
||||
student_row.append(None) # Pas de note
|
||||
|
||||
grading_elements_detailed_matrix.append(student_row)
|
||||
|
||||
# Préparer les couleurs des éléments basées sur les exercices pour une meilleure distinction
|
||||
grading_elements_colors = {}
|
||||
exercise_colors = [
|
||||
'#3b82f6', # Bleu
|
||||
'#10b981', # Vert
|
||||
'#f59e0b', # Orange
|
||||
'#8b5cf6', # Violet
|
||||
'#ef4444', # Rouge
|
||||
'#06b6d4', # Cyan
|
||||
'#84cc16', # Vert clair
|
||||
'#f97316', # Orange foncé
|
||||
]
|
||||
|
||||
exercises_seen = {}
|
||||
color_index = 0
|
||||
|
||||
for element_key in grading_elements_list:
|
||||
element_info = grading_elements_info[element_key]
|
||||
exercise = element_info['exercise']
|
||||
|
||||
# Assigner une couleur unique par exercice
|
||||
if exercise.id not in exercises_seen:
|
||||
exercises_seen[exercise.id] = exercise_colors[color_index % len(exercise_colors)]
|
||||
color_index += 1
|
||||
|
||||
grading_elements_colors[element_key] = exercises_seen[exercise.id]
|
||||
|
||||
# Récupérer la configuration des couleurs depuis la base de données
|
||||
from app_config import config_manager
|
||||
scale_colors = config_manager.get_competence_scale_values()
|
||||
|
||||
heatmap_grading_elements = {
|
||||
'students': students_list,
|
||||
'elements': grading_elements_list,
|
||||
'detailed_scores': grading_elements_detailed_matrix,
|
||||
'colors': grading_elements_colors,
|
||||
'scale_colors': scale_colors
|
||||
} if grading_elements_list else None
|
||||
|
||||
return render_template('assessment_results.html',
|
||||
assessment=assessment,
|
||||
students_scores=sorted_students,
|
||||
statistics=statistics,
|
||||
total_max_points=total_max_points,
|
||||
scores_json=scores)
|
||||
scores_json=scores,
|
||||
heatmap_competences=heatmap_competences,
|
||||
heatmap_domains=heatmap_domains,
|
||||
heatmap_grading_elements=heatmap_grading_elements)
|
||||
|
||||
@bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@handle_db_errors
|
||||
|
||||
@@ -62,6 +62,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap 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>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<canvas id="competencesHeatmap" width="800" height="400"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Heatmap 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>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<canvas id="domainsHeatmap" width="800" height="400"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Heatmap 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>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<canvas id="gradingElementsHeatmap" width="1200" height="600"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tableau des résultats -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
@@ -123,6 +168,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('scoresChart').getContext('2d');
|
||||
const scores = {{ scores_json | tojson }};
|
||||
|
||||
// Récupérer les données des élèves avec leurs noms et scores
|
||||
const studentsData = [
|
||||
{% for student_data in students_scores %}
|
||||
{
|
||||
name: "{{ student_data.student.last_name }} {{ student_data.student.first_name }}",
|
||||
score: {{ student_data.total_score }}
|
||||
}{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
// Créer les intervalles pour l'histogramme
|
||||
const totalMaxPoints = {{ total_max_points }};
|
||||
const minScore = 0;
|
||||
@@ -132,19 +187,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
const bins = [];
|
||||
const labels = [];
|
||||
const studentsInBins = []; // Nouveau: stocker les élèves pour chaque bin
|
||||
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const binStart = minScore + i * binWidth;
|
||||
const binEnd = minScore + (i + 1) * binWidth;
|
||||
bins.push(0);
|
||||
labels.push(`${binStart.toFixed(0)} - ${binEnd.toFixed(0)}`);
|
||||
studentsInBins.push([]); // Initialiser la liste d'élèves pour ce bin
|
||||
}
|
||||
|
||||
// Compter les scores dans chaque intervalle
|
||||
scores.forEach(score => {
|
||||
let binIndex = Math.floor((score - minScore) / binWidth);
|
||||
// Compter les scores dans chaque intervalle et stocker les noms des élèves
|
||||
studentsData.forEach(student => {
|
||||
let binIndex = Math.floor((student.score - minScore) / binWidth);
|
||||
if (binIndex >= numBins) binIndex = numBins - 1; // Pour le score maximum
|
||||
if (binIndex >= 0) bins[binIndex]++;
|
||||
if (binIndex >= 0) {
|
||||
bins[binIndex]++;
|
||||
studentsInBins[binIndex].push(`${student.name} (${student.score.toFixed(1)})`);
|
||||
}
|
||||
});
|
||||
|
||||
new Chart(ctx, {
|
||||
@@ -168,6 +228,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(tooltipItems) {
|
||||
const binIndex = tooltipItems[0].dataIndex;
|
||||
return labels[binIndex];
|
||||
},
|
||||
label: function(tooltipItem) {
|
||||
const binIndex = tooltipItem.dataIndex;
|
||||
const count = bins[binIndex];
|
||||
const students = studentsInBins[binIndex];
|
||||
|
||||
if (count === 0) {
|
||||
return 'Aucun élève';
|
||||
} else if (count === 1) {
|
||||
return '1 élève : ' + students[0];
|
||||
} else {
|
||||
return `${count} élèves : ${students.join(', ')}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
maxWidth: 400,
|
||||
bodyFont: {
|
||||
size: 12
|
||||
},
|
||||
titleFont: {
|
||||
size: 13,
|
||||
weight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
@@ -190,6 +279,490 @@ 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 %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user