diff --git a/docs/CONFIGURATION_SCALES.md b/docs/CONFIGURATION_SCALES.md index 0ea4605..cbb1b76 100644 --- a/docs/CONFIGURATION_SCALES.md +++ b/docs/CONFIGURATION_SCALES.md @@ -122,29 +122,63 @@ function calculateNoteColor(note, maxPoints, minColor, maxColor) { ## Utilisation dans le Code -### Calcul des Couleurs de Notes +### Application dans la Page de Notation + +Le dégradé HSL est **automatiquement appliqué dans la page de notation** (`/assessments//grading`) pour tous les éléments de type `"notes"`. + +#### Transmission de la Configuration ```python -# Côté serveur : Transmission de la configuration +# routes/grading.py - Route assessment_grading() notes_gradient = { 'min_color': config_manager.get('grading.notes_gradient.min_color', '#dc2626'), 'max_color': config_manager.get('grading.notes_gradient.max_color', '#059669'), 'enabled': config_manager.get('grading.notes_gradient.enabled', False) } + +return render_template('assessment_grading.html', + notes_gradient=notes_gradient, # ← Nouvelle transmission + # ... autres paramètres + ) ``` +#### Calcul Dynamique des Couleurs + ```javascript -// Côté client : Calcul dynamique +// Fonction globale pour calculer la couleur d'une note window.getNotesGradientColor = function(note, maxPoints) { - const minColor = '{{ notes_gradient.min_color }}'; - const maxColor = '{{ notes_gradient.max_color }}'; - const enabled = {{ notes_gradient.enabled|tojson }}; + if (!GRADING_CONFIG.notes_gradient.enabled) return '#6b7280'; - if (!enabled) return '#6b7280'; - return calculateNoteColor(note, maxPoints, minColor, maxColor); + const factor = Math.min(1, Math.max(0, note / maxPoints)); + return interpolateColorHSL( + GRADING_CONFIG.notes_gradient.min_color, + GRADING_CONFIG.notes_gradient.max_color, + factor + ); }; ``` +#### Application Automatique + +```javascript +// ColorManager.applyColorToInput() - Logique de colorisation +if (type === 'notes') { + // Valeurs spéciales (., d, etc.) → couleurs échelle + if (GRADING_CONFIG.scale_values[value] && isNaN(value)) { + // Couleur spéciale avec style italique + } + + // Notes numériques (2, 2.0, 15.5, etc.) → dégradé HSL + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + const noteColor = window.getNotesGradientColor(numValue, maxPoints); + input.style.color = '#374151'; // Texte gris doux + input.style.backgroundColor = hexToRgba(noteColor, 0.25); // Fond coloré + input.style.borderColor = hexToRgba(noteColor, 0.5); // Bordure accentuée + } +} +``` + ### Accès aux Valeurs d'Échelle ```python @@ -267,21 +301,54 @@ Dégradé notes : Orange → Bleu (évaluations /10) Valeurs spéciales : "NA" pour non applicable, "O" pour oral seulement ``` +## Indicateurs Visuels d'Erreur + +### Validation des Saisies + +Le système détecte automatiquement les **valeurs interdites** et applique un indicateur visuel très visible : + +```javascript +// Valeurs interdites → Indicateur rouge vif +if (!isValid) { + input.style.color = '#ffffff'; // Texte blanc + input.style.backgroundColor = '#dc2626'; // Fond rouge vif + input.style.borderColor = '#dc2626'; // Bordure rouge + input.style.borderWidth = '2px'; // Bordure épaisse + input.style.boxShadow = '0 0 0 3px rgba(220, 38, 38, 0.3)'; // Ombre rouge + input.style.fontWeight = 'bold'; // Texte en gras +} +``` + +### Exemples d'Erreurs Détectées + +**Pour les scores (type="score")** : +- Valeurs autres que 0, 1, 2, 3 ou valeurs spéciales configurées +- Exemples : `"5"`, `"1.5"`, `"abc"`, `"-1"` + +**Pour les notes (type="notes")** : +- Valeurs négatives : `"-5"` +- Valeurs supérieures au maximum : `"25"` (si max=20) +- Format invalide : `"abc"`, `"2.5.3"`, `"10,5,2"` + +### Reset Automatique +Dès qu'une valeur valide est saisie, tous les styles d'erreur sont automatiquement effacés et remplacés par la colorisation normale. + ## Impact UX ### Bénéfices Utilisateur - **Cohérence visuelle** : Couleurs automatiques selon performance -- **Simplicité** : Un seul bouton de sauvegarde -- **Feedback immédiat** : Prévisualisation temps réel +- **Feedback immédiat** : Erreurs visibles instantanément + dégradé temps réel +- **Distinction claire** : Notes vs Scores vs Valeurs spéciales +- **Simplicité** : Configuration centralisée, application automatique - **Flexibilité** : Adaptation aux pratiques pédagogiques ### Performance -- **Client** : Calculs JavaScript optimisés -- **Serveur** : Configuration mise en cache +- **Client** : Calculs JavaScript optimisés avec interpolation HSL native +- **Serveur** : Configuration mise en cache, transmission optimisée - **Base** : Index sur valeurs d'échelle -- **Rendu** : Templates précompilés +- **Rendu** : Colorisation temps réel sans rechargement --- **Documentation maintenue à jour - Version 2025** -*Dernière modification : Janvier 2025* \ No newline at end of file +*Dernière modification : 9 août 2025 - Ajout dégradé HSL et indicateurs d'erreur* \ No newline at end of file diff --git a/docs/backend/README.md b/docs/backend/README.md index 3bbaf8d..02ee236 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -156,6 +156,8 @@ notytex/ - ✅ **Dynamic Settings** : Configuration runtime modifiable - ✅ **Scales Management** : Échelles de notation configurables (0-3 + spéciales) - ✅ **Color Gradients** : Système dégradé couleurs notes avec HSL +- ✅ **Visual Feedback** : Indicateurs d'erreur visuels pour validation +- ✅ **Grading Integration** : Application automatique dans page de notation - ✅ **Business Rules** : Règles métier configurables - ✅ **Unified Interface** : Interface de configuration unifiée diff --git a/routes/grading.py b/routes/grading.py index 862b5fa..997b6a1 100644 --- a/routes/grading.py +++ b/routes/grading.py @@ -26,12 +26,20 @@ def assessment_grading(assessment_id): # Préparer les informations d'affichage pour les scores scale_values = config_manager.get_competence_scale_values() + # Récupérer la configuration du dégradé des notes + notes_gradient = { + 'min_color': config_manager.get('grading.notes_gradient.min_color', '#dc2626'), + 'max_color': config_manager.get('grading.notes_gradient.max_color', '#059669'), + 'enabled': config_manager.get('grading.notes_gradient.enabled', False) + } + return render_template('assessment_grading.html', assessment=assessment, students=students, grading_elements=grading_elements, existing_grades=existing_grades, scale_values=scale_values, + notes_gradient=notes_gradient, config_manager=config_manager) @bp.route('/assessments//grading/save', methods=['POST']) diff --git a/templates/assessment_grading.html b/templates/assessment_grading.html index 39bb250..66f472f 100644 --- a/templates/assessment_grading.html +++ b/templates/assessment_grading.html @@ -450,7 +450,12 @@ const GRADING_CONFIG = { '{{ value }}' : {{ scale_values[value] | tojson }}{% if not loop.last %},{% endif %} {% endfor %} }, - special_keys: Object.keys({{ scale_values | tojson }}).filter(key => !['0', '1', '2', '3'].includes(key)) + special_keys: Object.keys({{ scale_values | tojson }}).filter(key => !['0', '1', '2', '3'].includes(key)), + notes_gradient: { + min_color: '{{ notes_gradient.min_color }}', + max_color: '{{ notes_gradient.max_color }}', + enabled: {{ notes_gradient.enabled|tojson }} + } }; // ====== ÉTAT GLOBAL ====== @@ -508,6 +513,118 @@ class GradingState { const state = new GradingState(); +// ====== FONCTIONS D'INTERPOLATION HSL ====== +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) { + const toHex = (c) => { + const hex = Math.round(Math.max(0, Math.min(255, c))).toString(16); + return hex.length == 1 ? "0" + hex : hex; + }; + return "#" + toHex(r) + toHex(g) + toHex(b); +} + +function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } 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 = h / 360; + s = s / 100; + l = 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; + } 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); + + // Gérer la transition de teinte (chemin le plus court) + let h1 = hsl1.h; + let h2 = hsl2.h; + 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; + + const normalizedH = ((h % 360) + 360) % 360; + + const rgb = hslToRgb(normalizedH, s, l); + return rgbToHex(rgb.r, rgb.g, rgb.b); +} + +// Fonction globale pour calculer la couleur d'une note +window.getNotesGradientColor = function(note, maxPoints) { + if (!GRADING_CONFIG.notes_gradient.enabled) return '#6b7280'; + + const factor = Math.min(1, Math.max(0, note / maxPoints)); + return interpolateColorHSL( + GRADING_CONFIG.notes_gradient.min_color, + GRADING_CONFIG.notes_gradient.max_color, + factor + ); +}; + // ====== INITIALISATION ====== document.addEventListener('DOMContentLoaded', function() { setupKeyboardNavigation(); @@ -533,31 +650,24 @@ class ColorManager { } static applyColorToInput(input, value, type, isValid, maxPoints) { - // Reset styles + // Reset styles complètement input.className = input.className.replace(/border-\w+-\d+|bg-\w+-\d+/g, '').trim(); input.style.cssText = ''; + input.style.borderWidth = '1px'; // Reset border width + input.style.boxShadow = 'none'; // Reset box shadow if (!value || value.trim() === '') { input.style.color = '#6b7280'; return; } - // Couleurs configurées - if (GRADING_CONFIG.scale_values[value]) { - const config = GRADING_CONFIG.scale_values[value]; - input.style.color = config.color; - input.style.backgroundColor = this.hexToRgba(config.color, 0.1); - input.style.borderColor = this.hexToRgba(config.color, 0.3); - input.style.fontWeight = 'bold'; - - if (isNaN(value)) input.style.fontStyle = 'italic'; - return; - } - if (!isValid) { - input.style.color = '#dc2626'; - input.style.backgroundColor = '#fef2f2'; - input.style.borderColor = '#fca5a5'; + input.style.color = '#ffffff'; + input.style.backgroundColor = '#dc2626'; + input.style.borderColor = '#dc2626'; + input.style.borderWidth = '2px'; + input.style.fontWeight = 'bold'; + input.style.boxShadow = '0 0 0 3px rgba(220, 38, 38, 0.3)'; const message = type === 'score' ? 'Valeur autorisée : 0, 1, 2, 3 ou valeurs spéciales' : @@ -566,22 +676,43 @@ class ColorManager { return; } - // Gradient pour notes numériques + // Pour les scores : toujours utiliser les couleurs configurées de l'échelle + if (type === 'score') { + if (GRADING_CONFIG.scale_values[value]) { + const config = GRADING_CONFIG.scale_values[value]; + input.style.color = '#374151'; + input.style.backgroundColor = this.hexToRgba(config.color, 0.25); + input.style.borderColor = this.hexToRgba(config.color, 0.5); + input.style.fontWeight = 'bold'; + + if (isNaN(value)) input.style.fontStyle = 'italic'; + } + return; + } + + // Pour les notes : d'abord vérifier les valeurs spéciales, sinon utiliser le dégradé if (type === 'notes') { - const percentage = Math.min(100, (parseFloat(value) / maxPoints) * 100); - const colors = ['#ef4444', '#f6d32d', '#22c55e', '#059669']; - const thresholds = [40, 60, 80]; - - let colorIndex = 0; - for (let i = 0; i < thresholds.length; i++) { - if (percentage >= thresholds[i]) colorIndex = i + 1; + // Valeurs spéciales (non numériques comme ".", "d", etc.) + if (GRADING_CONFIG.scale_values[value] && isNaN(value)) { + const config = GRADING_CONFIG.scale_values[value]; + input.style.color = '#374151'; + input.style.backgroundColor = this.hexToRgba(config.color, 0.25); + input.style.borderColor = this.hexToRgba(config.color, 0.5); + input.style.fontWeight = 'bold'; + input.style.fontStyle = 'italic'; + return; } - const noteColor = colors[colorIndex]; - input.style.color = noteColor; - input.style.backgroundColor = this.hexToRgba(noteColor, 0.1); - input.style.borderColor = this.hexToRgba(noteColor, 0.3); - input.style.fontWeight = 'bold'; + // Notes numériques : utiliser le dégradé HSL + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + const noteColor = window.getNotesGradientColor(numValue, maxPoints); + input.style.color = '#374151'; + input.style.backgroundColor = this.hexToRgba(noteColor, 0.25); + input.style.borderColor = this.hexToRgba(noteColor, 0.5); + input.style.fontWeight = 'bold'; + input.style.fontStyle = 'normal'; + } } }