1848 lines
62 KiB
JavaScript
1848 lines
62 KiB
JavaScript
/**
|
|
* NOTYTEX - Class Dashboard Module
|
|
* Gestion avancée du dashboard de classe avec navigation par trimestre
|
|
* et progressive disclosure des statistiques
|
|
*/
|
|
|
|
class ClassDashboard {
|
|
constructor(classId, options = {}) {
|
|
this.classId = classId;
|
|
this.options = {
|
|
debounceTime: 300,
|
|
cacheTimeout: 5 * 60 * 1000, // 5 minutes
|
|
animationDuration: Notytex.config.transitions.normal,
|
|
enableTouchGestures: true,
|
|
...options
|
|
};
|
|
|
|
// État centralisé
|
|
this.state = {
|
|
currentTrimester: null,
|
|
expandedCards: new Set(),
|
|
cache: new Map(),
|
|
loading: false,
|
|
touchStartX: null,
|
|
touchStartY: null,
|
|
touchStartTime: null,
|
|
swipeDirection: null,
|
|
isInitialized: false,
|
|
currentDevice: this.detectDevice(),
|
|
intersectionObserver: null,
|
|
performanceMetrics: {
|
|
cls: 0,
|
|
lcp: 0,
|
|
fid: 0
|
|
}
|
|
};
|
|
|
|
// Éléments DOM cachés
|
|
this.elements = {};
|
|
|
|
// Charts instances
|
|
this.studentAveragesChart = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialisation du dashboard
|
|
*/
|
|
async init() {
|
|
try {
|
|
this.cacheElements();
|
|
this.restoreStateFromURL();
|
|
this.setupResponsiveBehavior();
|
|
this.setupIntersectionObserver();
|
|
this.setupPerformanceMonitoring();
|
|
await this.loadInitialData();
|
|
this.bindEvents();
|
|
this.setupAccessibility();
|
|
this.setupAdvancedTouchGestures();
|
|
this.state.isInitialized = true;
|
|
|
|
// Animer l'apparition du contenu
|
|
this.animateInitialLoad();
|
|
|
|
// Précharger les données intelligemment
|
|
this.setupSmartPrefetching();
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors de l\'initialisation du ClassDashboard:', error);
|
|
this.showError('Erreur lors du chargement du dashboard');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache les références aux éléments DOM
|
|
*/
|
|
cacheElements() {
|
|
this.elements = {
|
|
container: document.querySelector('[data-class-dashboard]'),
|
|
trimesterTabs: document.querySelectorAll('[data-trimester-tab]'),
|
|
statsCards: document.querySelectorAll('[data-stats-card]'),
|
|
loadingOverlay: document.querySelector('[data-loading-overlay]'),
|
|
errorContainer: document.querySelector('[data-error-container]'),
|
|
statsContent: document.querySelector('[data-stats-content]')
|
|
};
|
|
|
|
if (!this.elements.container) {
|
|
throw new Error('Conteneur du dashboard non trouvé');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restaure l'état depuis l'URL
|
|
*/
|
|
restoreStateFromURL() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
// Trimestre depuis l'URL
|
|
const trimesterParam = params.get('trimestre');
|
|
if (trimesterParam) {
|
|
const trimester = parseInt(trimesterParam);
|
|
if ([1, 2, 3].includes(trimester)) {
|
|
this.state.currentTrimester = trimester;
|
|
}
|
|
}
|
|
|
|
// Cards expandées depuis l'URL
|
|
const expandedParam = params.get('expanded');
|
|
if (expandedParam) {
|
|
expandedParam.split(',').forEach(cardType => {
|
|
this.state.expandedCards.add(cardType);
|
|
});
|
|
}
|
|
|
|
// Mettre à jour l'UI avec l'état restauré
|
|
this.updateTrimesterTabsUI();
|
|
this.updateExpandedCardsUI();
|
|
}
|
|
|
|
/**
|
|
* Charge les données initiales
|
|
*/
|
|
async loadInitialData() {
|
|
const trimester = this.state.currentTrimester;
|
|
return await this.fetchStats(trimester);
|
|
}
|
|
|
|
/**
|
|
* Liaison des événements
|
|
*/
|
|
bindEvents() {
|
|
// Navigation par trimestre
|
|
this.elements.trimesterTabs.forEach(tab => {
|
|
tab.addEventListener('click', this.handleTrimesterClick.bind(this));
|
|
});
|
|
|
|
// Les cartes affichent maintenant toutes leurs données directement
|
|
// Plus besoin d'event listeners pour expand/collapse
|
|
|
|
// Navigation clavier
|
|
document.addEventListener('keydown', this.handleKeyboardNavigation.bind(this));
|
|
|
|
// Gestion du redimensionnement
|
|
const debouncedResize = Notytex.utils.debounce(
|
|
this.handleResize.bind(this),
|
|
this.options.debounceTime
|
|
);
|
|
window.addEventListener('resize', debouncedResize);
|
|
|
|
// Touch gestures pour mobile
|
|
if (this.options.enableTouchGestures && this.isMobile()) {
|
|
this.setupTouchGestures();
|
|
}
|
|
|
|
// Gestion du back/forward du navigateur
|
|
window.addEventListener('popstate', this.handlePopState.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Configuration de l'accessibilité
|
|
*/
|
|
setupAccessibility() {
|
|
// ARIA labels pour les tabs
|
|
this.elements.trimesterTabs.forEach((tab, index) => {
|
|
tab.setAttribute('role', 'tab');
|
|
tab.setAttribute('aria-selected', 'false');
|
|
tab.setAttribute('tabindex', index === 0 ? '0' : '-1');
|
|
});
|
|
|
|
// ARIA pour les cards
|
|
this.elements.statsCards.forEach(card => {
|
|
const header = card.querySelector('[data-card-header]');
|
|
const content = card.querySelector('[data-card-content]');
|
|
|
|
if (header && content) {
|
|
const cardId = `card-${Math.random().toString(36).substr(2, 9)}`;
|
|
header.setAttribute('aria-expanded', 'false');
|
|
header.setAttribute('aria-controls', cardId);
|
|
content.setAttribute('id', cardId);
|
|
content.setAttribute('aria-hidden', 'true');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gestion des clics sur les onglets trimestre
|
|
*/
|
|
async handleTrimesterClick(event) {
|
|
event.preventDefault();
|
|
|
|
const tab = event.currentTarget;
|
|
const trimester = tab.dataset.trimesterTab;
|
|
const trimesterValue = trimester === 'global' ? null : parseInt(trimester);
|
|
|
|
if (trimesterValue === this.state.currentTrimester) {
|
|
return; // Déjà sélectionné
|
|
}
|
|
|
|
try {
|
|
await this.changeTrimester(trimesterValue);
|
|
} catch (error) {
|
|
console.error('Erreur lors du changement de trimestre:', error);
|
|
this.showError('Erreur lors du changement de trimestre');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Changement de trimestre avec animation
|
|
*/
|
|
async changeTrimester(trimester) {
|
|
// Mise à jour de l'état
|
|
this.state.currentTrimester = trimester;
|
|
|
|
// Animation de transition
|
|
await this.animateTrimesterTransition();
|
|
|
|
// Chargement des nouvelles données
|
|
await this.fetchStats(trimester);
|
|
|
|
// Mise à jour de l'UI
|
|
this.updateTrimesterTabsUI();
|
|
this.updateURL();
|
|
|
|
// Notification de succès
|
|
const trimesterName = trimester ? `Trimestre ${trimester}` : 'Vue globale';
|
|
Notytex.utils.showToast(`${trimesterName} chargé`, 'success', 1500);
|
|
}
|
|
|
|
/**
|
|
* Gestion des clics sur les headers de cards
|
|
*/
|
|
handleCardClick(event) {
|
|
event.preventDefault();
|
|
|
|
const header = event.currentTarget;
|
|
const card = header.closest('[data-stats-card]');
|
|
const cardType = card.dataset.statsCard;
|
|
|
|
this.toggleCard(cardType);
|
|
}
|
|
|
|
/**
|
|
* Toggle d'une card avec animation
|
|
*/
|
|
async toggleCard(cardType) {
|
|
const card = document.querySelector(`[data-stats-card="${cardType}"]`);
|
|
if (!card) return;
|
|
|
|
const isExpanded = this.state.expandedCards.has(cardType);
|
|
|
|
if (isExpanded) {
|
|
await this.collapseCard(cardType);
|
|
this.state.expandedCards.delete(cardType);
|
|
} else {
|
|
await this.expandCard(cardType);
|
|
this.state.expandedCards.add(cardType);
|
|
}
|
|
|
|
this.updateURL();
|
|
this.updateCardAccessibility(cardType, !isExpanded);
|
|
}
|
|
|
|
/**
|
|
* Expansion d'une card
|
|
*/
|
|
async expandCard(cardType) {
|
|
const card = document.querySelector(`[data-stats-card="${cardType}"]`);
|
|
const content = card.querySelector('[data-card-content]');
|
|
const icon = card.querySelector('[data-expand-icon]');
|
|
|
|
// Rotation de l'icône
|
|
if (icon) {
|
|
icon.style.transform = 'rotate(180deg)';
|
|
}
|
|
|
|
// Animation d'expansion
|
|
content.style.display = 'block';
|
|
content.style.height = '0px';
|
|
content.style.opacity = '0';
|
|
|
|
// Force reflow
|
|
content.offsetHeight;
|
|
|
|
const targetHeight = content.scrollHeight;
|
|
|
|
content.style.transition = `height ${this.options.animationDuration}ms ease-in-out, opacity ${this.options.animationDuration}ms ease-in-out`;
|
|
content.style.height = `${targetHeight}px`;
|
|
content.style.opacity = '1';
|
|
|
|
// Cleanup après animation
|
|
setTimeout(() => {
|
|
content.style.height = 'auto';
|
|
content.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
|
|
// Charger le contenu détaillé si nécessaire
|
|
await this.loadCardDetailedContent(cardType);
|
|
}
|
|
|
|
/**
|
|
* Collapse d'une card
|
|
*/
|
|
async collapseCard(cardType) {
|
|
const card = document.querySelector(`[data-stats-card="${cardType}"]`);
|
|
const content = card.querySelector('[data-card-content]');
|
|
const icon = card.querySelector('[data-expand-icon]');
|
|
|
|
// Rotation de l'icône
|
|
if (icon) {
|
|
icon.style.transform = 'rotate(0deg)';
|
|
}
|
|
|
|
// Animation de collapse
|
|
const currentHeight = content.offsetHeight;
|
|
content.style.height = `${currentHeight}px`;
|
|
content.style.transition = `height ${this.options.animationDuration}ms ease-in-out, opacity ${this.options.animationDuration}ms ease-in-out`;
|
|
|
|
// Force reflow
|
|
content.offsetHeight;
|
|
|
|
content.style.height = '0px';
|
|
content.style.opacity = '0';
|
|
|
|
// Cleanup après animation
|
|
setTimeout(() => {
|
|
content.style.display = 'none';
|
|
content.style.height = '';
|
|
content.style.opacity = '';
|
|
content.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
}
|
|
|
|
/**
|
|
* Chargement des données statistiques
|
|
*/
|
|
async fetchStats(trimester) {
|
|
const cacheKey = `stats-${this.classId}-${trimester || 'global'}`;
|
|
|
|
// Vérifier le cache
|
|
if (this.isCacheValid(cacheKey)) {
|
|
const cachedData = this.state.cache.get(cacheKey);
|
|
this.updateStatsUI(cachedData.data);
|
|
return cachedData.data;
|
|
}
|
|
|
|
try {
|
|
this.showLoading();
|
|
|
|
const url = trimester
|
|
? `/classes/${this.classId}/stats?trimestre=${trimester}`
|
|
: `/classes/${this.classId}/stats`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Erreur HTTP: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Mise en cache avec timestamp
|
|
this.state.cache.set(cacheKey, {
|
|
data: data,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
this.updateStatsUI(data);
|
|
return data;
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des statistiques:', error);
|
|
this.showError('Impossible de charger les statistiques');
|
|
throw error;
|
|
} finally {
|
|
this.hideLoading();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chargement du contenu détaillé d'une card
|
|
*/
|
|
async loadCardDetailedContent(cardType) {
|
|
const card = document.querySelector(`[data-stats-card="${cardType}"]`);
|
|
const detailContainer = card.querySelector('[data-detail-content]');
|
|
|
|
if (!detailContainer || detailContainer.dataset.loaded === 'true') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Skeleton loading
|
|
detailContainer.innerHTML = this.createSkeletonHTML(cardType);
|
|
|
|
// Pour l'instant, nous n'avons pas de contenu détaillé spécialisé
|
|
// Les données sont déjà affichées dans les cards principales
|
|
detailContainer.innerHTML = '<div class="text-center text-gray-500 text-sm py-2">Détails supplémentaires bientôt disponibles</div>';
|
|
detailContainer.dataset.loaded = 'true';
|
|
|
|
} catch (error) {
|
|
console.error(`Erreur lors du chargement des détails ${cardType}:`, error);
|
|
detailContainer.innerHTML = '<p class="text-red-600 text-sm">Erreur lors du chargement</p>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'interface utilisateur avec les nouvelles données
|
|
*/
|
|
updateStatsUI(statsData) {
|
|
// Mise à jour des cards de domaines
|
|
this.updateDomainsCard(statsData.domains);
|
|
|
|
// Mise à jour des cards de compétences
|
|
this.updateCompetencesCard(statsData.competences);
|
|
|
|
// Mise à jour des résultats
|
|
if (statsData.results) {
|
|
this.updateResultsCard(statsData.results);
|
|
}
|
|
|
|
// Animation d'apparition
|
|
this.animateStatsUpdate();
|
|
}
|
|
|
|
|
|
/**
|
|
* Mise à jour de la card domaines
|
|
*/
|
|
updateDomainsCard(domainsData) {
|
|
const card = document.querySelector('[data-stats-card="domains"]');
|
|
if (!card || !domainsData) return;
|
|
|
|
// Mettre à jour le compteur
|
|
const countElement = card.querySelector('[data-domains-count]');
|
|
if (countElement) {
|
|
this.animateNumber(countElement, domainsData.length);
|
|
}
|
|
|
|
// Mettre à jour la liste
|
|
const container = card.querySelector('[data-domains-list]');
|
|
if (!container) return;
|
|
|
|
if (domainsData.length === 0) {
|
|
container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucun domaine évalué</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = domainsData.map(domain => `
|
|
<div class="bg-green-50 rounded-lg p-3 border border-green-100">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<div class="flex items-center">
|
|
<div class="w-3 h-3 rounded-full mr-3" style="background-color: ${domain.color}"></div>
|
|
<span class="text-green-800 font-medium">${domain.name}</span>
|
|
</div>
|
|
<span class="text-green-900 font-bold text-sm">${domain.total_points}pts</span>
|
|
</div>
|
|
<div class="text-xs text-green-600 pl-6">
|
|
${domain.elements_count} ${domain.elements_count > 1 ? 'éléments évalués' : 'élément évalué'}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de la card compétences
|
|
*/
|
|
updateCompetencesCard(competencesData) {
|
|
const card = document.querySelector('[data-stats-card="competences"]');
|
|
if (!card || !competencesData) return;
|
|
|
|
// Mettre à jour le compteur
|
|
const countElement = card.querySelector('[data-competences-count]');
|
|
if (countElement) {
|
|
this.animateNumber(countElement, competencesData.length);
|
|
}
|
|
|
|
// Mettre à jour la liste
|
|
const container = card.querySelector('[data-competences-list]');
|
|
if (!container) return;
|
|
|
|
if (competencesData.length === 0) {
|
|
container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucune compétence évaluée</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = competencesData.map(competence => `
|
|
<div class="bg-purple-50 rounded-lg p-3 border border-purple-100">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<div class="flex items-center">
|
|
<div class="w-3 h-3 rounded-full mr-3" style="background-color: ${competence.color}"></div>
|
|
<span class="text-purple-800 font-medium">${competence.name}</span>
|
|
</div>
|
|
<span class="text-purple-900 font-bold text-sm">${competence.total_points}pts</span>
|
|
</div>
|
|
<div class="text-xs text-purple-600 pl-6">
|
|
${competence.elements_count} ${competence.elements_count > 1 ? 'éléments évalués' : 'élément évalué'}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de la card résultats
|
|
*/
|
|
updateResultsCard(resultsData) {
|
|
const card = document.querySelector('[data-stats-card="results"]');
|
|
if (!card || !resultsData) return;
|
|
|
|
const statsMappings = {
|
|
'mean': 'average', // L'API renvoie "average" mais le DOM attend "mean"
|
|
'median': 'median',
|
|
'std_dev': 'std_dev',
|
|
'min': 'min',
|
|
'max': 'max',
|
|
'assessments_count': 'assessments_count'
|
|
};
|
|
|
|
Object.entries(statsMappings).forEach(([domKey, apiKey]) => {
|
|
const element = card.querySelector(`[data-result="${domKey}"]`);
|
|
if (element && resultsData[apiKey] !== undefined) {
|
|
let value;
|
|
if (domKey === 'assessments_count') {
|
|
// Format spécial pour le nombre d'évaluations
|
|
const count = resultsData[apiKey];
|
|
console.log('DEBUG assessments_count:', count, 'type:', typeof count);
|
|
console.log('DEBUG full resultsData:', resultsData);
|
|
|
|
// Vérification de sécurité
|
|
const safeCount = (count !== undefined && count !== null && !isNaN(count)) ? count : 0;
|
|
value = `${safeCount} évaluation${safeCount > 1 ? 's' : ''}`;
|
|
|
|
// Pour le texte, on met à jour directement sans animation
|
|
element.textContent = value;
|
|
} else {
|
|
// Format numérique pour les autres statistiques
|
|
value = typeof resultsData[apiKey] === 'number'
|
|
? resultsData[apiKey].toFixed(1)
|
|
: resultsData[apiKey];
|
|
this.animateNumber(element, value);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Mise à jour de l'histogramme des moyennes des élèves
|
|
if (resultsData.student_averages_distribution) {
|
|
this.updateStudentAveragesChart(resultsData.student_averages_distribution);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Animation des nombres
|
|
*/
|
|
animateNumber(element, targetValue, duration = 1000) {
|
|
const startValue = parseFloat(element.textContent) || 0;
|
|
const isInteger = Number.isInteger(targetValue);
|
|
|
|
if (startValue === targetValue) return;
|
|
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Easing function
|
|
const easeOut = 1 - Math.pow(1 - progress, 3);
|
|
|
|
const currentValue = startValue + (targetValue - startValue) * easeOut;
|
|
const displayValue = isInteger ? Math.round(currentValue) : currentValue.toFixed(1);
|
|
|
|
element.textContent = displayValue;
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'histogramme des moyennes des élèves avec Chart.js
|
|
*/
|
|
updateStudentAveragesChart(distribution) {
|
|
const canvas = document.getElementById('studentAveragesChart');
|
|
const noDataElement = document.querySelector('[data-chart-no-data]');
|
|
|
|
if (!canvas) return;
|
|
|
|
// Vérifier s'il y a des données
|
|
const hasData = distribution && distribution.length > 0 && distribution.some(item => item.count > 0);
|
|
|
|
if (!hasData) {
|
|
if (noDataElement) {
|
|
noDataElement.style.display = 'flex';
|
|
}
|
|
// Détruire le graphique existant
|
|
if (this.studentAveragesChart) {
|
|
this.studentAveragesChart.destroy();
|
|
this.studentAveragesChart = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (noDataElement) {
|
|
noDataElement.style.display = 'none';
|
|
}
|
|
|
|
// Détruire le graphique existant
|
|
if (this.studentAveragesChart) {
|
|
this.studentAveragesChart.destroy();
|
|
}
|
|
|
|
// Préparer les données
|
|
const labels = distribution.map(item => item.range);
|
|
const data = distribution.map(item => item.count);
|
|
const maxCount = Math.max(...data);
|
|
|
|
// Créer le graphique
|
|
this.studentAveragesChart = new Chart(canvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Nombre d\'élèves',
|
|
data: data,
|
|
backgroundColor: 'rgba(251, 146, 60, 0.8)',
|
|
borderColor: 'rgba(251, 146, 60, 1)',
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
borderSkipped: false
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
titleColor: 'white',
|
|
bodyColor: 'white',
|
|
borderColor: 'rgba(251, 146, 60, 1)',
|
|
borderWidth: 1,
|
|
callbacks: {
|
|
title: function(tooltipItems) {
|
|
return `Moyenne: ${tooltipItems[0].label}`;
|
|
},
|
|
label: function(context) {
|
|
const count = context.parsed.y;
|
|
return `${count} élève${count > 1 ? 's' : ''}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
display: true,
|
|
grid: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
color: 'rgba(251, 146, 60, 0.8)',
|
|
font: {
|
|
size: 10
|
|
},
|
|
maxRotation: 0
|
|
}
|
|
},
|
|
y: {
|
|
display: true,
|
|
beginAtZero: true,
|
|
max: maxCount > 0 ? Math.ceil(maxCount * 1.1) : 1,
|
|
grid: {
|
|
color: 'rgba(251, 146, 60, 0.1)'
|
|
},
|
|
ticks: {
|
|
color: 'rgba(251, 146, 60, 0.8)',
|
|
font: {
|
|
size: 10
|
|
},
|
|
stepSize: 1
|
|
}
|
|
}
|
|
},
|
|
animation: {
|
|
duration: this.options.animationDuration || 800,
|
|
easing: 'easeInOutCubic'
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'histogramme (legacy - gardé pour compatibilité)
|
|
*/
|
|
updateHistogram(distribution) {
|
|
const histogramContainer = document.querySelector('[data-histogram]');
|
|
if (!histogramContainer) return;
|
|
|
|
const maxValue = Math.max(...distribution);
|
|
const bars = distribution.map((value, index) => {
|
|
const height = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
|
return `
|
|
<div class="flex flex-col items-center">
|
|
<div class="bg-blue-200 border border-blue-300 rounded-t"
|
|
style="height: ${height}px; width: 20px; min-height: 2px;"></div>
|
|
<span class="text-xs mt-1">${index}</span>
|
|
<span class="text-xs text-gray-600">${value}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
histogramContainer.innerHTML = `
|
|
<div class="flex items-end justify-center space-x-1 h-32">
|
|
${bars}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'interface des onglets trimestre
|
|
*/
|
|
updateTrimesterTabsUI() {
|
|
this.elements.trimesterTabs.forEach(tab => {
|
|
const tabTrimester = tab.dataset.trimesterTab;
|
|
const tabValue = tabTrimester === 'global' ? null : parseInt(tabTrimester);
|
|
|
|
if (tabValue === this.state.currentTrimester) {
|
|
tab.classList.add('active', 'bg-blue-600', 'text-white');
|
|
tab.classList.remove('bg-white', 'text-gray-700');
|
|
tab.setAttribute('aria-selected', 'true');
|
|
} else {
|
|
tab.classList.remove('active', 'bg-blue-600', 'text-white');
|
|
tab.classList.add('bg-white', 'text-gray-700');
|
|
tab.setAttribute('aria-selected', 'false');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'interface des cards expandées
|
|
*/
|
|
updateExpandedCardsUI() {
|
|
this.elements.statsCards.forEach(card => {
|
|
const cardType = card.dataset.statsCard;
|
|
const isExpanded = this.state.expandedCards.has(cardType);
|
|
|
|
const content = card.querySelector('[data-card-content]');
|
|
const icon = card.querySelector('[data-expand-icon]');
|
|
|
|
if (content) {
|
|
content.style.display = isExpanded ? 'block' : 'none';
|
|
}
|
|
|
|
if (icon) {
|
|
icon.style.transform = isExpanded ? 'rotate(180deg)' : 'rotate(0deg)';
|
|
}
|
|
|
|
this.updateCardAccessibility(cardType, isExpanded);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'accessibilité d'une card
|
|
*/
|
|
updateCardAccessibility(cardType, isExpanded) {
|
|
const card = document.querySelector(`[data-stats-card="${cardType}"]`);
|
|
if (!card) return;
|
|
|
|
const header = card.querySelector('[data-card-header]');
|
|
const content = card.querySelector('[data-card-content]');
|
|
|
|
if (header) {
|
|
header.setAttribute('aria-expanded', isExpanded.toString());
|
|
}
|
|
|
|
if (content) {
|
|
content.setAttribute('aria-hidden', (!isExpanded).toString());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gestion de la navigation clavier
|
|
*/
|
|
handleKeyboardNavigation(event) {
|
|
if (!this.state.isInitialized) return;
|
|
|
|
// Navigation dans les onglets avec les flèches
|
|
if (event.target.matches('[data-trimester-tab]')) {
|
|
let currentIndex = Array.from(this.elements.trimesterTabs).indexOf(event.target);
|
|
|
|
switch (event.key) {
|
|
case 'ArrowLeft':
|
|
event.preventDefault();
|
|
currentIndex = currentIndex > 0 ? currentIndex - 1 : this.elements.trimesterTabs.length - 1;
|
|
this.elements.trimesterTabs[currentIndex].focus();
|
|
break;
|
|
|
|
case 'ArrowRight':
|
|
event.preventDefault();
|
|
currentIndex = currentIndex < this.elements.trimesterTabs.length - 1 ? currentIndex + 1 : 0;
|
|
this.elements.trimesterTabs[currentIndex].focus();
|
|
break;
|
|
|
|
case 'Enter':
|
|
case ' ':
|
|
event.preventDefault();
|
|
event.target.click();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Toggle des cards avec Entrée/Espace
|
|
if (event.target.matches('[data-card-header]') && (event.key === 'Enter' || event.key === ' ')) {
|
|
event.preventDefault();
|
|
event.target.click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configuration des gestes tactiles avancés pour mobile
|
|
*/
|
|
setupAdvancedTouchGestures() {
|
|
if (!this.state.currentDevice.isMobile) return;
|
|
|
|
const container = this.elements.container;
|
|
let isMultiTouch = false;
|
|
let initialDistance = 0;
|
|
let longPressTimer = null;
|
|
|
|
container.addEventListener('touchstart', (event) => {
|
|
this.state.touchStartX = event.touches[0].clientX;
|
|
this.state.touchStartY = event.touches[0].clientY;
|
|
this.state.touchStartTime = Date.now();
|
|
this.state.swipeDirection = null;
|
|
|
|
// Détection multi-touch
|
|
isMultiTouch = event.touches.length > 1;
|
|
if (isMultiTouch && event.touches.length === 2) {
|
|
initialDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
|
|
}
|
|
|
|
// Long press detection
|
|
longPressTimer = setTimeout(() => {
|
|
this.handleLongPress(event.touches[0]);
|
|
this.addRippleEffect(event.target, event.touches[0]);
|
|
}, 500);
|
|
|
|
}, { passive: true });
|
|
|
|
container.addEventListener('touchmove', (event) => {
|
|
if (!this.state.touchStartX) return;
|
|
|
|
// Clear long press timer on move
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
longPressTimer = null;
|
|
}
|
|
|
|
const touchCurrentX = event.touches[0].clientX;
|
|
const touchCurrentY = event.touches[0].clientY;
|
|
const diffX = touchCurrentX - this.state.touchStartX;
|
|
const diffY = touchCurrentY - this.state.touchStartY;
|
|
|
|
// Détection de la direction du swipe
|
|
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 10) {
|
|
this.state.swipeDirection = diffX > 0 ? 'right' : 'left';
|
|
this.showSwipeIndicator(this.state.swipeDirection);
|
|
}
|
|
|
|
// Pinch to expand detection
|
|
if (isMultiTouch && event.touches.length === 2) {
|
|
const currentDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
|
|
const scale = currentDistance / initialDistance;
|
|
|
|
if (scale > 1.2) {
|
|
this.handlePinchExpand(event.target);
|
|
}
|
|
}
|
|
|
|
// Prevent horizontal scroll if swiping
|
|
if (Math.abs(diffX) > 30) {
|
|
event.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
container.addEventListener('touchend', (event) => {
|
|
// Clear timers
|
|
if (longPressTimer) {
|
|
clearTimeout(longPressTimer);
|
|
longPressTimer = null;
|
|
}
|
|
|
|
this.hideSwipeIndicators();
|
|
|
|
if (!this.state.touchStartX) return;
|
|
|
|
const touchEndX = event.changedTouches[0].clientX;
|
|
const touchEndTime = Date.now();
|
|
const diffX = this.state.touchStartX - touchEndX;
|
|
const swipeTime = touchEndTime - this.state.touchStartTime;
|
|
const minSwipeDistance = 50;
|
|
const maxSwipeTime = 300;
|
|
|
|
// Swipe validation
|
|
if (Math.abs(diffX) > minSwipeDistance && swipeTime < maxSwipeTime) {
|
|
const currentTrimesterIndex = this.getCurrentTrimesterIndex();
|
|
const velocity = Math.abs(diffX) / swipeTime;
|
|
|
|
// Add haptic feedback if available
|
|
this.triggerHapticFeedback('light');
|
|
|
|
if (diffX > 0 && currentTrimesterIndex < this.elements.trimesterTabs.length - 1) {
|
|
// Swipe vers la gauche - trimestre suivant
|
|
this.animatedTrimesterChange(currentTrimesterIndex + 1, 'right');
|
|
} else if (diffX < 0 && currentTrimesterIndex > 0) {
|
|
// Swipe vers la droite - trimestre précédent
|
|
this.animatedTrimesterChange(currentTrimesterIndex - 1, 'left');
|
|
}
|
|
}
|
|
|
|
this.resetTouchState();
|
|
}, { passive: true });
|
|
}
|
|
|
|
/**
|
|
* Obtient l'index du trimestre actuellement sélectionné
|
|
*/
|
|
getCurrentTrimesterIndex() {
|
|
return Array.from(this.elements.trimesterTabs).findIndex(tab => {
|
|
const tabTrimester = tab.dataset.trimesterTab;
|
|
const tabValue = tabTrimester === 'global' ? null : parseInt(tabTrimester);
|
|
return tabValue === this.state.currentTrimester;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gestion du redimensionnement de la fenêtre
|
|
*/
|
|
handleResize() {
|
|
// Réinitialiser les hauteurs des cards expandées
|
|
this.elements.statsCards.forEach(card => {
|
|
if (this.state.expandedCards.has(card.dataset.statsCard)) {
|
|
const content = card.querySelector('[data-card-content]');
|
|
if (content && content.style.height !== 'auto') {
|
|
content.style.height = 'auto';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gestion de l'historique du navigateur
|
|
*/
|
|
handlePopState(event) {
|
|
this.restoreStateFromURL();
|
|
this.updateTrimesterTabsUI();
|
|
this.updateExpandedCardsUI();
|
|
|
|
// Recharger les données si nécessaire
|
|
if (event.state && event.state.trimester !== this.state.currentTrimester) {
|
|
this.fetchStats(this.state.currentTrimester);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Animation de transition entre trimestres
|
|
*/
|
|
async animateTrimesterTransition() {
|
|
const content = this.elements.statsContent;
|
|
if (!content) return;
|
|
|
|
// Animation de sortie
|
|
content.style.opacity = '0.6';
|
|
content.style.transform = 'translateY(10px)';
|
|
content.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
|
|
|
|
await new Promise(resolve => setTimeout(resolve, this.options.animationDuration / 2));
|
|
|
|
// Animation d'entrée
|
|
content.style.opacity = '1';
|
|
content.style.transform = 'translateY(0)';
|
|
|
|
setTimeout(() => {
|
|
content.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
}
|
|
|
|
/**
|
|
* Animation lors de la mise à jour des statistiques
|
|
*/
|
|
animateStatsUpdate() {
|
|
this.elements.statsCards.forEach((card, index) => {
|
|
card.style.opacity = '0';
|
|
card.style.transform = 'translateY(20px)';
|
|
|
|
setTimeout(() => {
|
|
card.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
|
|
card.style.opacity = '1';
|
|
card.style.transform = 'translateY(0)';
|
|
|
|
setTimeout(() => {
|
|
card.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
}, index * 100);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animation du chargement initial
|
|
*/
|
|
animateInitialLoad() {
|
|
const elements = [
|
|
...this.elements.trimesterTabs,
|
|
...this.elements.statsCards
|
|
];
|
|
|
|
elements.forEach((element, index) => {
|
|
element.style.opacity = '0';
|
|
element.style.transform = 'translateY(30px)';
|
|
|
|
setTimeout(() => {
|
|
element.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
|
|
element.style.opacity = '1';
|
|
element.style.transform = 'translateY(0)';
|
|
|
|
setTimeout(() => {
|
|
element.style.transition = '';
|
|
}, this.options.animationDuration);
|
|
}, index * 50);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mise à jour de l'URL avec l'état actuel
|
|
*/
|
|
updateURL() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
// Trimestre
|
|
if (this.state.currentTrimester) {
|
|
params.set('trimestre', this.state.currentTrimester.toString());
|
|
} else {
|
|
params.delete('trimestre');
|
|
}
|
|
|
|
// Cards expandées
|
|
if (this.state.expandedCards.size > 0) {
|
|
params.set('expanded', Array.from(this.state.expandedCards).join(','));
|
|
} else {
|
|
params.delete('expanded');
|
|
}
|
|
|
|
const url = new URL(window.location);
|
|
url.search = params.toString();
|
|
|
|
// Mise à jour de l'historique sans rechargement
|
|
window.history.replaceState(
|
|
{ trimester: this.state.currentTrimester },
|
|
document.title,
|
|
url.toString()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Affichage de l'état de chargement
|
|
*/
|
|
showLoading() {
|
|
this.state.loading = true;
|
|
|
|
if (this.elements.loadingOverlay) {
|
|
this.elements.loadingOverlay.classList.remove('hidden');
|
|
}
|
|
|
|
// Désactiver les contrôles
|
|
this.elements.trimesterTabs.forEach(tab => {
|
|
tab.style.pointerEvents = 'none';
|
|
tab.style.opacity = '0.6';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Masquage de l'état de chargement
|
|
*/
|
|
hideLoading() {
|
|
this.state.loading = false;
|
|
|
|
if (this.elements.loadingOverlay) {
|
|
this.elements.loadingOverlay.classList.add('hidden');
|
|
}
|
|
|
|
// Réactiver les contrôles
|
|
this.elements.trimesterTabs.forEach(tab => {
|
|
tab.style.pointerEvents = '';
|
|
tab.style.opacity = '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Affichage d'une erreur
|
|
*/
|
|
showError(message) {
|
|
if (this.elements.errorContainer) {
|
|
this.elements.errorContainer.innerHTML = `
|
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium text-red-800">${message}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
this.elements.errorContainer.classList.remove('hidden');
|
|
}
|
|
|
|
Notytex.utils.showToast(message, 'error');
|
|
}
|
|
|
|
/**
|
|
* Génération du HTML squelette pour le chargement
|
|
*/
|
|
createSkeletonHTML(cardType) {
|
|
const skeletonItems = cardType === 'results' ? 5 : 3;
|
|
|
|
return Array(skeletonItems).fill().map(() => `
|
|
<div class="flex items-center justify-between py-2 animate-pulse">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-4 h-4 bg-gray-300 rounded-full"></div>
|
|
<div class="h-4 bg-gray-300 rounded w-24"></div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="h-4 bg-gray-300 rounded w-16 mb-1"></div>
|
|
<div class="h-3 bg-gray-200 rounded w-20"></div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/**
|
|
* Rendu du contenu détaillé d'une card
|
|
*/
|
|
renderDetailedContent(cardType, data, container) {
|
|
switch (cardType) {
|
|
case 'domains':
|
|
this.renderDomainsDetail(data, container);
|
|
break;
|
|
case 'competences':
|
|
this.renderCompetencesDetail(data, container);
|
|
break;
|
|
case 'results':
|
|
this.renderResultsDetail(data, container);
|
|
break;
|
|
default:
|
|
container.innerHTML = '<p class="text-gray-600 text-sm">Contenu détaillé non disponible</p>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rendu des détails des domaines
|
|
*/
|
|
renderDomainsDetail(data, container) {
|
|
if (!data.detailed_breakdown) {
|
|
container.innerHTML = '<p class="text-gray-600 text-sm">Détails non disponibles</p>';
|
|
return;
|
|
}
|
|
|
|
const html = data.detailed_breakdown.map(domain => `
|
|
<div class="border border-gray-200 rounded-lg p-3 mb-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h4 class="font-semibold text-gray-900">${domain.name}</h4>
|
|
<span class="text-sm text-gray-600">${domain.total_points.toFixed(1)} pts</span>
|
|
</div>
|
|
<div class="space-y-1">
|
|
${domain.assessments.map(assessment => `
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-700">${assessment.name}</span>
|
|
<span class="font-medium">${assessment.points.toFixed(1)} pts</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* Rendu des détails des compétences
|
|
*/
|
|
renderCompetencesDetail(data, container) {
|
|
if (!data.detailed_breakdown) {
|
|
container.innerHTML = '<p class="text-gray-600 text-sm">Détails non disponibles</p>';
|
|
return;
|
|
}
|
|
|
|
const html = data.detailed_breakdown.map(competence => `
|
|
<div class="border border-gray-200 rounded-lg p-3 mb-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h4 class="font-semibold text-gray-900">${competence.name}</h4>
|
|
<span class="text-sm text-gray-600">${competence.total_points.toFixed(1)} pts</span>
|
|
</div>
|
|
<div class="space-y-1">
|
|
${competence.assessments.map(assessment => `
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-gray-700">${assessment.name}</span>
|
|
<span class="font-medium">${assessment.points.toFixed(1)} pts</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* Rendu des détails des résultats
|
|
*/
|
|
renderResultsDetail(data, container) {
|
|
if (!data.student_rankings) {
|
|
container.innerHTML = '<p class="text-gray-600 text-sm">Classement non disponible</p>';
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<div class="space-y-2">
|
|
<h4 class="font-semibold text-gray-900 mb-3">Top 10 des étudiants</h4>
|
|
${data.student_rankings.slice(0, 10).map((student, index) => `
|
|
<div class="flex items-center justify-between py-2 ${index < 3 ? 'bg-yellow-50 rounded px-2' : ''}">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium">
|
|
${index + 1}
|
|
</span>
|
|
<span class="text-gray-900">${student.name}</span>
|
|
</div>
|
|
<span class="font-semibold text-gray-900">${student.average.toFixed(1)}/20</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
/**
|
|
* Vérification de la validité du cache
|
|
*/
|
|
isCacheValid(key) {
|
|
const cached = this.state.cache.get(key);
|
|
if (!cached) return false;
|
|
|
|
const age = Date.now() - cached.timestamp;
|
|
return age < this.options.cacheTimeout;
|
|
}
|
|
|
|
/**
|
|
* Détection avancée du type d'appareil
|
|
*/
|
|
detectDevice() {
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
const isTouchDevice = 'ontouchstart' in window;
|
|
const hasHover = window.matchMedia('(hover: hover)').matches;
|
|
const connection = navigator.connection;
|
|
|
|
return {
|
|
isMobile: width < 768,
|
|
isTablet: width >= 768 && width < 1024,
|
|
isDesktop: width >= 1024,
|
|
isTouchDevice,
|
|
hasHover,
|
|
pixelRatio: window.devicePixelRatio || 1,
|
|
orientation: height > width ? 'portrait' : 'landscape',
|
|
isSlowNetwork: connection ? connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g' : false,
|
|
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Détection d'un appareil mobile (legacy)
|
|
*/
|
|
isMobile() {
|
|
return this.state.currentDevice.isMobile;
|
|
}
|
|
|
|
/**
|
|
* Configuration du comportement responsive
|
|
*/
|
|
setupResponsiveBehavior() {
|
|
// Écouter les changements d'orientation
|
|
window.addEventListener('orientationchange', () => {
|
|
setTimeout(() => {
|
|
this.state.currentDevice = this.detectDevice();
|
|
this.adaptLayoutToDevice();
|
|
}, 100);
|
|
});
|
|
|
|
// Écouter les changements de taille d'écran
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
this.handleResponsiveResize(entries);
|
|
});
|
|
|
|
if (this.elements.container) {
|
|
resizeObserver.observe(this.elements.container);
|
|
}
|
|
|
|
// Adapter le layout initial
|
|
this.adaptLayoutToDevice();
|
|
}
|
|
|
|
/**
|
|
* Adaptation du layout selon le device
|
|
*/
|
|
adaptLayoutToDevice() {
|
|
const { isMobile, isTablet, isSlowNetwork, reducedMotion } = this.state.currentDevice;
|
|
|
|
if (isMobile) {
|
|
this.enableMobileOptimizations();
|
|
} else if (isTablet) {
|
|
this.enableTabletOptimizations();
|
|
} else {
|
|
this.enableDesktopOptimizations();
|
|
}
|
|
|
|
if (isSlowNetwork) {
|
|
this.enableLowBandwidthMode();
|
|
}
|
|
|
|
if (reducedMotion) {
|
|
this.disableAnimations();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimisations mobile
|
|
*/
|
|
enableMobileOptimizations() {
|
|
// Sticky navigation
|
|
if (this.elements.container) {
|
|
this.elements.container.classList.add('mobile-layout');
|
|
}
|
|
|
|
// Accordéon behavior pour les cards
|
|
this.elements.statsCards.forEach(card => {
|
|
card.classList.add('mobile-accordion');
|
|
});
|
|
|
|
// Touch optimizations
|
|
document.body.style.touchAction = 'manipulation';
|
|
}
|
|
|
|
/**
|
|
* Optimisations tablette
|
|
*/
|
|
enableTabletOptimizations() {
|
|
if (this.elements.container) {
|
|
this.elements.container.classList.add('tablet-layout');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimisations desktop
|
|
*/
|
|
enableDesktopOptimizations() {
|
|
if (this.elements.container) {
|
|
this.elements.container.classList.add('desktop-layout');
|
|
}
|
|
|
|
// Enable hover effects
|
|
this.elements.statsCards.forEach(card => {
|
|
card.classList.add('hover-enabled');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mode faible bande passante
|
|
*/
|
|
enableLowBandwidthMode() {
|
|
// Réduire la qualité des animations
|
|
this.options.animationDuration = this.options.animationDuration * 0.5;
|
|
|
|
// Lazy loading plus agressif
|
|
this.options.cacheTimeout = this.options.cacheTimeout * 2;
|
|
|
|
if (this.elements.container) {
|
|
this.elements.container.classList.add('low-bandwidth');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Désactiver les animations
|
|
*/
|
|
disableAnimations() {
|
|
this.options.animationDuration = 0;
|
|
|
|
if (this.elements.container) {
|
|
this.elements.container.classList.add('no-animations');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configuration de l'IntersectionObserver pour le lazy loading
|
|
*/
|
|
setupIntersectionObserver() {
|
|
if (!('IntersectionObserver' in window)) return;
|
|
|
|
this.state.intersectionObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
this.lazyLoadCard(entry.target);
|
|
this.state.intersectionObserver.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, {
|
|
rootMargin: '50px',
|
|
threshold: 0.1
|
|
});
|
|
|
|
// Observer les cards non visibles
|
|
this.elements.statsCards.forEach(card => {
|
|
if (!this.isElementInViewport(card)) {
|
|
this.state.intersectionObserver.observe(card);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lazy loading d'une card
|
|
*/
|
|
lazyLoadCard(cardElement) {
|
|
const cardType = cardElement.dataset.statsCard;
|
|
if (cardType && this.state.expandedCards.has(cardType)) {
|
|
this.loadCardDetailedContent(cardType);
|
|
}
|
|
|
|
// Animation d'apparition
|
|
cardElement.style.opacity = '0';
|
|
cardElement.style.transform = 'translateY(30px)';
|
|
|
|
requestAnimationFrame(() => {
|
|
cardElement.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
|
|
cardElement.style.opacity = '1';
|
|
cardElement.style.transform = 'translateY(0)';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configuration du monitoring de performance
|
|
*/
|
|
setupPerformanceMonitoring() {
|
|
if (!('PerformanceObserver' in window)) return;
|
|
|
|
// Cumulative Layout Shift (CLS)
|
|
const clsObserver = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
if (!entry.hadRecentInput) {
|
|
this.state.performanceMetrics.cls += entry.value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
|
} catch (e) {
|
|
// Fallback pour les anciens navigateurs
|
|
}
|
|
|
|
// Largest Contentful Paint (LCP)
|
|
const lcpObserver = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
const lastEntry = entries[entries.length - 1];
|
|
this.state.performanceMetrics.lcp = lastEntry.startTime;
|
|
});
|
|
|
|
try {
|
|
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
} catch (e) {
|
|
// Fallback pour les anciens navigateurs
|
|
}
|
|
|
|
// First Input Delay (FID)
|
|
const fidObserver = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
this.state.performanceMetrics.fid = entry.processingStart - entry.startTime;
|
|
}
|
|
});
|
|
|
|
try {
|
|
fidObserver.observe({ type: 'first-input', buffered: true });
|
|
} catch (e) {
|
|
// Fallback pour les anciens navigateurs
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Préchargement intelligent des données
|
|
*/
|
|
setupSmartPrefetching() {
|
|
// Prédire le prochain trimestre basé sur l'usage
|
|
const predictedTrimester = this.predictNextTrimester();
|
|
|
|
if (predictedTrimester && !this.state.currentDevice.isSlowNetwork) {
|
|
setTimeout(() => {
|
|
this.prefetchTrimesterData(predictedTrimester);
|
|
}, 2000);
|
|
}
|
|
|
|
// Précharger les détails des cards visibles
|
|
const visibleCards = Array.from(this.elements.statsCards).filter(card => {
|
|
return this.isElementInViewport(card) && !this.state.expandedCards.has(card.dataset.statsCard);
|
|
});
|
|
|
|
visibleCards.forEach((card, index) => {
|
|
setTimeout(() => {
|
|
this.prefetchCardDetails(card.dataset.statsCard);
|
|
}, index * 500);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Prédiction du prochain trimestre
|
|
*/
|
|
predictNextTrimester() {
|
|
const current = this.state.currentTrimester;
|
|
if (!current) return 1; // Si global, prédire trimestre 1
|
|
if (current < 3) return current + 1;
|
|
return null; // Pas de prédiction pour le trimestre 3
|
|
}
|
|
|
|
/**
|
|
* Préchargement des données d'un trimestre
|
|
*/
|
|
async prefetchTrimesterData(trimester) {
|
|
const cacheKey = `stats-${this.classId}-${trimester}`;
|
|
|
|
if (!this.isCacheValid(cacheKey)) {
|
|
try {
|
|
const url = `/classes/${this.classId}/stats?trimestre=${trimester}`;
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.state.cache.set(cacheKey, {
|
|
data: data,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.log('Erreur lors du préchargement:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Préchargement des détails d'une card
|
|
*/
|
|
async prefetchCardDetails(cardType) {
|
|
const trimester = this.state.currentTrimester;
|
|
const url = trimester
|
|
? `/classes/${this.classId}/stats/${cardType}?trimestre=${trimester}`
|
|
: `/classes/${this.classId}/stats/${cardType}`;
|
|
|
|
try {
|
|
await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.log('Erreur lors du préchargement des détails:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gestion du redimensionnement responsive
|
|
*/
|
|
handleResponsiveResize(entries) {
|
|
for (const entry of entries) {
|
|
const newDevice = this.detectDevice();
|
|
|
|
// Si changement de catégorie d'appareil
|
|
if (newDevice.isMobile !== this.state.currentDevice.isMobile ||
|
|
newDevice.isTablet !== this.state.currentDevice.isTablet) {
|
|
|
|
this.state.currentDevice = newDevice;
|
|
this.adaptLayoutToDevice();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Distance entre deux points tactiles
|
|
*/
|
|
getTouchDistance(touch1, touch2) {
|
|
const dx = touch1.clientX - touch2.clientX;
|
|
const dy = touch1.clientY - touch2.clientY;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
/**
|
|
* Gestion du long press
|
|
*/
|
|
handleLongPress(touch) {
|
|
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
const card = element?.closest('[data-stats-card]');
|
|
|
|
if (card) {
|
|
// Afficher menu contextuel ou actions rapides
|
|
this.showContextMenu(card, touch.clientX, touch.clientY);
|
|
this.triggerHapticFeedback('medium');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gestion du pinch to expand
|
|
*/
|
|
handlePinchExpand(target) {
|
|
const card = target.closest('[data-stats-card]');
|
|
if (card && !this.state.expandedCards.has(card.dataset.statsCard)) {
|
|
this.toggleCard(card.dataset.statsCard);
|
|
this.triggerHapticFeedback('heavy');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Affichage des indicateurs de swipe
|
|
*/
|
|
showSwipeIndicator(direction) {
|
|
const existing = this.elements.container.querySelector('.swipe-indicator');
|
|
if (existing) existing.remove();
|
|
|
|
const indicator = document.createElement('div');
|
|
indicator.className = `swipe-indicator ${direction} visible`;
|
|
this.elements.container.appendChild(indicator);
|
|
}
|
|
|
|
/**
|
|
* Masquer les indicateurs de swipe
|
|
*/
|
|
hideSwipeIndicators() {
|
|
const indicators = this.elements.container.querySelectorAll('.swipe-indicator');
|
|
indicators.forEach(indicator => {
|
|
indicator.classList.remove('visible');
|
|
setTimeout(() => indicator.remove(), 300);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Changement de trimestre avec animation directionnelle
|
|
*/
|
|
async animatedTrimesterChange(newIndex, direction) {
|
|
const newTab = this.elements.trimesterTabs[newIndex];
|
|
if (!newTab) return;
|
|
|
|
// Animation de sortie
|
|
this.elements.statsContent.style.transform = `translateX(${direction === 'right' ? '-20px' : '20px'})`;
|
|
this.elements.statsContent.style.opacity = '0.6';
|
|
|
|
// Changer le trimestre
|
|
await newTab.click();
|
|
|
|
// Animation d'entrée
|
|
setTimeout(() => {
|
|
this.elements.statsContent.style.transform = 'translateX(0)';
|
|
this.elements.statsContent.style.opacity = '1';
|
|
}, 150);
|
|
}
|
|
|
|
/**
|
|
* Effet ripple sur touch
|
|
*/
|
|
addRippleEffect(element, touch) {
|
|
const ripple = document.createElement('span');
|
|
const rect = element.getBoundingClientRect();
|
|
const size = Math.max(rect.width, rect.height);
|
|
|
|
ripple.style.width = ripple.style.height = size + 'px';
|
|
ripple.style.left = (touch.clientX - rect.left - size / 2) + 'px';
|
|
ripple.style.top = (touch.clientY - rect.top - size / 2) + 'px';
|
|
ripple.className = 'ripple';
|
|
|
|
element.style.position = 'relative';
|
|
element.style.overflow = 'hidden';
|
|
element.appendChild(ripple);
|
|
|
|
setTimeout(() => ripple.remove(), 600);
|
|
}
|
|
|
|
/**
|
|
* Feedback haptique
|
|
*/
|
|
triggerHapticFeedback(intensity = 'light') {
|
|
if ('vibrate' in navigator) {
|
|
const patterns = {
|
|
light: [10],
|
|
medium: [20],
|
|
heavy: [30, 10, 30]
|
|
};
|
|
navigator.vibrate(patterns[intensity] || patterns.light);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Menu contextuel
|
|
*/
|
|
showContextMenu(card, x, y) {
|
|
const existing = document.querySelector('.context-menu');
|
|
if (existing) existing.remove();
|
|
|
|
const menu = document.createElement('div');
|
|
menu.className = 'context-menu';
|
|
menu.style.cssText = `
|
|
position: fixed;
|
|
top: ${y}px;
|
|
left: ${x}px;
|
|
background: white;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
z-index: 1000;
|
|
padding: 8px 0;
|
|
`;
|
|
|
|
const actions = [
|
|
{ label: 'Développer', action: () => this.expandCard(card.dataset.statsCard) },
|
|
{ label: 'Actualiser', action: () => this.refreshCardData(card.dataset.statsCard) }
|
|
];
|
|
|
|
actions.forEach(({ label, action }) => {
|
|
const item = document.createElement('button');
|
|
item.textContent = label;
|
|
item.className = 'block w-full text-left px-4 py-2 hover:bg-gray-50';
|
|
item.onclick = () => {
|
|
action();
|
|
menu.remove();
|
|
};
|
|
menu.appendChild(item);
|
|
});
|
|
|
|
document.body.appendChild(menu);
|
|
setTimeout(() => menu.remove(), 3000);
|
|
}
|
|
|
|
/**
|
|
* Reset de l'état tactile
|
|
*/
|
|
resetTouchState() {
|
|
this.state.touchStartX = null;
|
|
this.state.touchStartY = null;
|
|
this.state.touchStartTime = null;
|
|
this.state.swipeDirection = null;
|
|
}
|
|
|
|
/**
|
|
* Vérification si un élément est dans le viewport
|
|
*/
|
|
isElementInViewport(element) {
|
|
const rect = element.getBoundingClientRect();
|
|
return (
|
|
rect.top >= 0 &&
|
|
rect.left >= 0 &&
|
|
rect.bottom <= window.innerHeight &&
|
|
rect.right <= window.innerWidth
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Actualisation des données d'une card
|
|
*/
|
|
async refreshCardData(cardType) {
|
|
const trimester = this.state.currentTrimester;
|
|
const cacheKey = `stats-${this.classId}-${trimester || 'global'}`;
|
|
|
|
// Supprimer du cache
|
|
this.state.cache.delete(cacheKey);
|
|
|
|
// Recharger
|
|
await this.fetchStats(trimester);
|
|
|
|
Notytex.utils.showToast('Données actualisées', 'success');
|
|
}
|
|
|
|
/**
|
|
* Nettoyage des ressources
|
|
*/
|
|
destroy() {
|
|
// Supprimer les écouteurs d'événements
|
|
window.removeEventListener('resize', this.handleResize);
|
|
window.removeEventListener('popstate', this.handlePopState);
|
|
document.removeEventListener('keydown', this.handleKeyboardNavigation);
|
|
|
|
// Nettoyer les observers
|
|
if (this.state.intersectionObserver) {
|
|
this.state.intersectionObserver.disconnect();
|
|
}
|
|
|
|
// Nettoyer les charts
|
|
if (this.studentAveragesChart) {
|
|
this.studentAveragesChart.destroy();
|
|
this.studentAveragesChart = null;
|
|
}
|
|
|
|
// Vider le cache
|
|
this.state.cache.clear();
|
|
|
|
// Nettoyer les éléments temporaires
|
|
document.querySelectorAll('.context-menu, .swipe-indicator, .ripple').forEach(el => el.remove());
|
|
|
|
// Réinitialiser l'état
|
|
this.state.isInitialized = false;
|
|
}
|
|
}
|
|
|
|
// Export pour utilisation globale
|
|
window.ClassDashboard = ClassDashboard;
|
|
|
|
// Auto-initialisation si les éléments sont présents
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const dashboardContainer = document.querySelector('[data-class-dashboard]');
|
|
if (dashboardContainer) {
|
|
const classId = dashboardContainer.dataset.classId;
|
|
if (classId) {
|
|
window.currentClassDashboard = new ClassDashboard(classId);
|
|
}
|
|
}
|
|
}); |