This commit is contained in:
2026-04-02 14:15:26 +02:00
parent 0cc8ab8540
commit af75a09c18
11 changed files with 734 additions and 472 deletions
+82 -21
View File
@@ -1,27 +1,50 @@
<auth-panel>
<!--
==========================================================================
COMPOSANT <auth-panel>
<!-- Bouton connexion dans le header (utilisateur non connecté) -->
RÔLE : gestion complète de l'authentification Firebase dans le header.
COMPORTEMENT VISIBLE :
- Si non connecté : bouton "Connexion" → ouvre une modale
- Si connecté : email de l'utilisateur + bouton "Déconnexion"
MODALE D'AUTHENTIFICATION :
- Onglet "Connexion" : formulaire email/mot de passe pour se connecter
- Onglet "Inscription": même formulaire pour créer un compte Firebase
PROPS reçues depuis <app> :
- user : objet utilisateur Firebase (ou null si non connecté)
- onauth : callback appelé après connexion/inscription réussie
- onlogout : callback appelé après déconnexion réussie
==========================================================================
-->
<!-- Bouton "Connexion" dans le header — visible uniquement si pas connecté -->
<div class="auth-header-btn" if={ !props.user }>
<button class="btn btn-auth" onclick={ ouvrirModale }>
<span class="auth-icon"></span> Connexion
</button>
</div>
<!-- Email + bouton déconnexion dans le header (utilisateur connecté) -->
<!-- Informations utilisateur dans le header — visible uniquement si connecté -->
<div class="auth-user-info" if={ props.user }>
<span class="auth-email">{ props.user.email }</span>
<button class="btn btn-auth-logout" onclick={ seDeconnecter }>Déconnexion</button>
</div>
<!-- Modale d'authentification -->
<!-- Modale d'authentification (overlay + contenu centré) -->
<!-- onclick={ cliquerFond } ferme la modale si on clique en dehors -->
<div class="auth-modal-overlay" if={ state.visible } onclick={ cliquerFond }>
<div class="auth-modal">
<!-- Croix de fermeture -->
<button class="auth-modal-close" onclick={ fermerModale }>✕</button>
<!-- Titre dynamique : "Connexion" ou "Créer un compte" selon l'onglet actif -->
<h2 class="auth-modal-title">{ state.titre }</h2>
<!-- Onglets Connexion / Inscription -->
<!-- Onglets pour basculer entre connexion et inscription -->
<div class="auth-tabs">
<button class={ state.classBtnConnexion } onclick={ afficherConnexion }>
Connexion
@@ -31,7 +54,8 @@
</button>
</div>
<!-- Formulaire -->
<!-- Formulaire unique utilisé pour les deux modes (connexion et inscription) -->
<!-- onsubmit={ validerFormulaire } intercepte la soumission native du formulaire -->
<form onsubmit={ validerFormulaire } class="auth-form">
<div class="auth-field">
@@ -55,11 +79,12 @@
/>
</div>
<!-- Message d'erreur -->
<!-- Message d'erreur : affiché uniquement si state.erreur est défini -->
<div class="auth-error" if={ state.erreur }>
{ state.erreur }
</div>
<!-- Bouton désactivé pendant le chargement pour éviter les doubles soumissions -->
<button type="submit" class="btn btn-primary auth-submit" disabled={ state.chargement }>
{ state.labelBouton }
</button>
@@ -71,32 +96,45 @@
<script>
export default {
// ========================================================================
// ÉTAT LOCAL du composant
// visible : true si la modale est ouverte
// mode : 'connexion' ou 'inscription' (détermine l'action Firebase)
// chargement : true pendant l'appel Firebase (désactive le bouton submit)
// erreur : message d'erreur à afficher (null si pas d'erreur)
// titre, labelBouton, classBtn... : textes dynamiques selon le mode actif
// ========================================================================
state: {
visible: false,
mode: 'connexion',
chargement: false,
erreur: null,
titre: 'Connexion',
labelBouton: 'Se connecter',
classBtnConnexion: 'auth-tab active',
visible: false,
mode: 'connexion',
chargement: false,
erreur: null,
titre: 'Connexion',
labelBouton: 'Se connecter',
classBtnConnexion: 'auth-tab active',
classBtnInscription: 'auth-tab'
},
// Ouvrir la modale et effacer les erreurs précédentes
ouvrirModale() {
this.update({ visible: true, erreur: null });
},
// Fermer la modale et effacer les erreurs
fermerModale() {
this.update({ visible: false, erreur: null });
},
// Fermer si l'utilisateur clique en dehors de la modale
// Fermer si l'utilisateur clique sur l'overlay (fond sombre)
// e.target === e.currentTarget : vrai seulement si on clique sur le fond lui-même
// et non sur le contenu de la modale (qui stopperait la propagation)
cliquerFond(e) {
if (e.target === e.currentTarget) {
this.fermerModale();
}
},
// Basculer vers l'onglet "Connexion"
afficherConnexion() {
this.update({
mode: 'connexion',
@@ -108,6 +146,7 @@
});
},
// Basculer vers l'onglet "Inscription"
afficherInscription() {
this.update({
mode: 'inscription',
@@ -119,28 +158,44 @@
});
},
// ========================================================================
// validerFormulaire(e)
// RÔLE : soumettre le formulaire via Firebase Auth selon le mode actif.
//
// Étapes :
// 1. e.preventDefault() : empêche le rechargement de la page (comportement natif)
// 2. Lecture des champs email et password
// 3. Appel Firebase (createAccount ou login) selon state.mode
// 4. Si succès : fermer la modale et appeler props.onauth
// 5. Si échec : traduire le code d'erreur Firebase en message lisible
//
// Les codes d'erreur Firebase (err.code) sont des chaînes standardisées
// ex: "auth/email-already-in-use", "auth/wrong-password", etc.
// ========================================================================
async validerFormulaire(e) {
e.preventDefault();
e.preventDefault(); // empêche la soumission HTML classique (rechargement de page)
var email = e.target.email.value.trim();
var password = e.target.password.value;
var services = window.firebaseServices;
var email = e.target.email.value.trim();
var password = e.target.password.value;
var services = window.firebaseServices;
this.update({ chargement: true, erreur: null });
this.update({ chargement: true, erreur: null }); // désactive le bouton pendant l'appel
try {
if (this.state.mode === 'inscription') {
await services.createAccount(email, password);
await services.createAccount(email, password); // crée le compte Firebase
} else {
await services.login(email, password);
await services.login(email, password); // connecte l'utilisateur
}
this.update({ visible: false, chargement: false });
// Notifie le parent que l'authentification a réussi
this.props.onauth && this.props.onauth();
} catch (err) {
// Traduction des codes d'erreur Firebase en messages utilisateur compréhensibles
var messageErreur = 'Une erreur est survenue.';
if (err.code === 'auth/email-already-in-use') {
@@ -159,9 +214,15 @@
}
},
// ========================================================================
// seDeconnecter()
// RÔLE : déconnecter l'utilisateur via Firebase et notifier le parent.
// app.riot réagit en vidant la sélection (props.onlogout → surDeconnexion).
// ========================================================================
async seDeconnecter() {
try {
await window.firebaseServices.logout();
// Notifie le parent pour qu'il vide la sélection et mette à jour l'UI
this.props.onlogout && this.props.onlogout();
} catch (err) {
console.error('Erreur déconnexion :', err);
+114 -45
View File
@@ -1,13 +1,35 @@
<comparateur-view>
<!--
==========================================================================
COMPOSANT <comparateur-view>
<!-- ============== CAS : des formations sont sélectionnées ============== -->
RÔLE : comparer plusieurs formations côte à côte et estimer les chances
d'admission selon le profil de l'utilisateur.
PROPS reçues depuis <app> :
- formations : tableau des formations sélectionnées par l'utilisateur
- onretirer : callback(id) → retirer une formation de la sélection
- onvider : callback() → vider toute la sélection
FONCTIONNEMENT DE L'ESTIMATION :
Un score est calculé sur 100 points répartis en 3 critères :
- Taux d'accès de la formation (facilité d'accès globale) → max 30 pts
- Note de l'étudiant (sur 20) → max 40 pts
- % de bacheliers de la même série intégrés → max 30 pts
Le score final détermine une mention : Très favorable / Favorable / Possible / Difficile / Très difficile
==========================================================================
-->
<!-- CAS 1 : au moins une formation est sélectionnée → affichage du comparateur -->
<div class="detail-card comparateur-card" if={ props.formations.length > 0 }>
<h2>Comparateur de formations</h2>
<p>Choisis ton profil pour estimer tes chances d'intégration.</p>
<!-- Contrôles du profil utilisateur : note, série, critère de tri -->
<div class="compare-controls">
<div>
<!-- Note moyenne de l'étudiant (utilisée dans le calcul du score) -->
<label><b>Note moyenne :</b></label><br />
<input
type="number"
@@ -20,6 +42,7 @@
</div>
<div>
<!-- Série bac : détermine quel % de bacheliers de ce type ont été acceptés -->
<label><b>Série :</b></label><br />
<select onchange={ mettreAJourSerie }>
<option value="general" selected={ state.serie === 'general' }>Général</option>
@@ -29,6 +52,7 @@
</div>
<div>
<!-- Critère de tri des cartes de formations -->
<label><b>Trier par :</b></label><br />
<select onchange={ mettreAJourTri }>
<option value="nom" selected={ state.sortBy === 'nom' }>Nom</option>
@@ -43,6 +67,8 @@
<hr />
<!-- Boucle sur les formations triées (obtenirSelectionTriee retourne le tableau ordonné) -->
<!-- classeCarte(f) applique une classe CSS différente selon l'estimation (couleur de la carte) -->
<div each={ f in obtenirSelectionTriee() } key={ f.id } class={ classeCarte(f) }>
<h4>{ f.nom }</h4>
@@ -52,6 +78,7 @@
<p><b>Capacité :</b> { f.capacite }</p>
<p><b>Taux d'accès :</b> { f.tauxAcces }%</p>
<!-- Répartition des intégrés par type de bac -->
<p>
<b>Intégrés :</b>
Général { f.pctGeneral }% /
@@ -59,20 +86,22 @@
Pro { f.pctPro }%
</p>
<!-- Résultat de l'estimation avec badge coloré + détail des critères -->
<p class="estimation-result">
<span class={ classeEstimation(f) }>
{ estimerFormation(f) }
{ estimerFormation(f) } <!-- ex: "Favorable", "Difficile"... -->
</span>
<span class="estimation-detail">{ detailEstimation(f) }</span>
<span class="estimation-detail">{ detailEstimation(f) }</span> <!-- ex: "Taux 42% · Gén 35% · Note 14/20" -->
</p>
<!-- Bouton pour retirer cette formation de la sélection -->
<button class="btn btn-small btn-outline" onclick={ retirerSelection.bind(this, f.id) }>
Retirer
</button>
</div>
</div>
<!-- ============== CAS : aucune formation sélectionnée ============== -->
<!-- CAS 2 : aucune formation sélectionnée → message d'aide -->
<div class="message" if={ props.formations.length === 0 }>
<h3>Aucune formation sélectionnée</h3>
<p>Retourne à la <a href="#/">recherche</a> et clique sur "Ajouter à la sélection" pour comparer des formations.</p>
@@ -81,58 +110,64 @@
<script>
export default {
// État local au comparateur (note, série, tri)
// ========================================================================
// ÉTAT LOCAL du composant
// note : note moyenne de l'étudiant (défaut 12/20)
// serie : type de bac ('general' | 'techno' | 'pro')
// sortBy : critère de tri courant ('nom' | 'ville' | 'taux' | 'estimation')
// ========================================================================
state: {
note: 12,
serie: 'general',
sortBy: 'nom'
},
// ----- Mise à jour du profil utilisateur -----
// --- Mise à jour du profil utilisateur ---
// Chaque handler lit la valeur du champ et met à jour l'état → re-rendu automatique
mettreAJourNote(e) { this.update({ note: Number(e.target.value) }); },
mettreAJourSerie(e) { this.update({ serie: e.target.value }); },
mettreAJourTri(e) { this.update({ sortBy: e.target.value }); },
mettreAJourNote(e) {
this.update({ note: Number(e.target.value) });
},
mettreAJourSerie(e) {
this.update({ serie: e.target.value });
},
mettreAJourTri(e) {
this.update({ sortBy: e.target.value });
},
// ----- Actions sur la sélection (déléguées au parent via props) -----
retirerSelection(id) {
this.props.onretirer(id);
},
viderSelection() {
this.props.onvider();
},
// ----- Tri des formations -----
// --- Actions sur la sélection (déléguées au parent via props) ---
// Ces fonctions ne modifient pas l'état local : elles délèguent au parent (app.riot)
retirerSelection(id) { this.props.onretirer(id); },
viderSelection() { this.props.onvider(); },
// ========================================================================
// obtenirSelectionTriee()
// RÔLE : retourner une copie triée du tableau de formations selon state.sortBy.
//
// On utilise slice() pour copier sans modifier le tableau original.
// localeCompare() trie correctement les chaînes avec accents et majuscules.
// ========================================================================
obtenirSelectionTriee() {
var selection = (this.props.formations || []).slice();
var selection = (this.props.formations || []).slice(); // copie du tableau
var soi = this;
if (this.state.sortBy === 'taux') {
// Tri décroissant par taux d'accès (le plus accessible en premier)
selection.sort(function(a, b) { return b.tauxAcces - a.tauxAcces; });
} else if (this.state.sortBy === 'ville') {
// Tri alphabétique par ville (localeCompare gère le français correctement)
selection.sort(function(a, b) { return a.ville.localeCompare(b.ville); });
} else if (this.state.sortBy === 'estimation') {
// Tri décroissant par score (la formation la plus accessible en premier)
selection.sort(function(a, b) { return soi.calculerScore(b) - soi.calculerScore(a); });
} else {
// Tri alphabétique par nom de formation (ordre par défaut)
selection.sort(function(a, b) { return a.nom.localeCompare(b.nom); });
}
return selection;
},
// ----- Calcul d'estimation -----
// ========================================================================
// pourcentageSerie(f)
// RÔLE : retourner le pourcentage d'admis de la série bac choisie par l'utilisateur.
//
// Si l'utilisateur a sélectionné "Technologique", on retourne f.pctTechno
// (% de bacheliers techno parmi les admis de cette formation).
// ========================================================================
pourcentageSerie(f) {
if (this.state.serie === 'general') { return f.pctGeneral || 0; }
if (this.state.serie === 'techno') { return f.pctTechno || 0; }
@@ -140,37 +175,61 @@
return 0;
},
// ========================================================================
// calculerScore(f)
// RÔLE : calculer un score de 0 à 100 estimant les chances d'admission.
//
// ALGORITHME :
// Critère 1 — Taux d'accès de la formation (max 30 pts)
// → Une formation avec 80%+ d'accès donne 30 pts (très accessible)
// → Une formation avec <15% d'accès donne 2 pts (très sélective)
//
// Critère 2 — Note de l'étudiant (max 40 pts)
// → 17+ /20 donne 40 pts
// → <9 /20 donne 0 pt
//
// Critère 3 — % de bacheliers de la même série (max 30 pts)
// → Si 60%+ des admis sont du même type de bac, c'est bon signe
// → Si <5%, ce type de bac est rarement accepté dans cette formation
//
// REMARQUE : ce calcul est une ESTIMATION heuristique, non un algorithme officiel.
// ========================================================================
calculerScore(f) {
var score = 0;
var note = this.state.note;
var tauxAcces = f.tauxAcces || 0;
var pctSerie = this.pourcentageSerie(f);
// Critère 1 : taux d'accès de la formation
if (tauxAcces >= 80) { score += 30; }
// Critère 1 : taux d'accès de la formation (facilité globale d'entrée)
if (tauxAcces >= 80) { score += 30; } // très accessible
else if (tauxAcces >= 50) { score += 24; }
else if (tauxAcces >= 30) { score += 16; }
else if (tauxAcces >= 15) { score += 8; }
else { score += 2; }
else { score += 2; } // très sélectif
// Critère 2 : note de l'étudiant
if (note >= 17) { score += 40; }
if (note >= 17) { score += 40; } // excellent
else if (note >= 15) { score += 32; }
else if (note >= 13) { score += 22; }
else if (note >= 11) { score += 14; }
else if (note >= 9) { score += 6; }
else { score += 0; }
else if (note >= 9) { score += 6; }
else { score += 0; } // note insuffisante
// Critère 3 : proportion de bacheliers de la même série acceptés
if (pctSerie >= 60) { score += 30; }
// Critère 3 : proportion de bacheliers de la même série acceptés dans cette formation
if (pctSerie >= 60) { score += 30; } // la série est très représentée
else if (pctSerie >= 40) { score += 24; }
else if (pctSerie >= 20) { score += 16; }
else if (pctSerie >= 5) { score += 8; }
else { score += 0; }
else if (pctSerie >= 5) { score += 8; }
else { score += 0; } // la série est presque absente
return score;
return score; // score total entre 0 et 100
},
// ========================================================================
// estimerFormation(f)
// RÔLE : convertir le score numérique en une mention textuelle.
// Les seuils sont calibrés pour donner une répartition équilibrée.
// ========================================================================
estimerFormation(f) {
var score = this.calculerScore(f);
if (score >= 85) { return 'Très favorable'; }
@@ -180,8 +239,10 @@
return 'Très difficile';
},
// ----- Classes CSS selon l'estimation -----
// --- Classes CSS dynamiques selon l'estimation ---
// Applique un style coloré différent selon le résultat (vert → rouge)
// Classe pour le badge d'estimation (texte inline)
classeEstimation(f) {
var r = this.estimerFormation(f);
if (r === 'Très favorable') { return 'estimate tres-favorable'; }
@@ -191,6 +252,7 @@
return 'estimate tres-difficile';
},
// Classe pour la carte entière (fond coloré)
classeCarte(f) {
var r = this.estimerFormation(f);
if (r === 'Très favorable') { return 'card card-tres-favorable'; }
@@ -200,12 +262,19 @@
return 'card card-tres-difficile';
},
// ========================================================================
// detailEstimation(f)
// RÔLE : générer une ligne de détail lisible avec les 3 critères utilisés.
// Exemple : "Taux 42% · Gén 35% · Note 14/20"
// Permet à l'utilisateur de comprendre d'où vient l'estimation.
// ========================================================================
detailEstimation(f) {
var tauxAcces = f.tauxAcces || 0;
var pctSerie = this.pourcentageSerie(f);
var nomSerie = '';
if (this.state.serie === 'general') { nomSerie = 'Gén'; }
// Libellé court de la série pour l'affichage
if (this.state.serie === 'general') { nomSerie = 'Gén'; }
else if (this.state.serie === 'techno') { nomSerie = 'Techno'; }
else { nomSerie = 'Pro'; }
+118 -20
View File
@@ -1,4 +1,25 @@
<detail-view>
<!--
==========================================================================
COMPOSANT <detail-view>
RÔLE : fiche détaillée d'une formation avec :
- Informations générales (établissement, ville, capacité...)
- Tableau de la phase principale d'admission (par type de bac)
- Tableau de la phase complémentaire
- Timeline de vitesse de remplissage (30 mai / 16 juin / 11 juillet)
- Graphiques Charts.css : répartition bac, mentions, profil sociologique
- Historique 20202025 (appel à chargerHistoriqueFormation via l'API)
PROPS reçues depuis <app> :
- formation : objet Formation normalisé (via creerFormation dans formation.js)
- onback : callback() → retour à la liste de résultats
BIBLIOTHÈQUES UTILISÉES :
- Charts.css : graphiques déclaratifs via HTML/CSS uniquement (pas de JS)
Les graphiques sont construits dynamiquement via innerHTML
==========================================================================
-->
<div if={ props.formation } class="detail-page">
<h2>Formation</h2>
<h1 class="formation-title">{ props.formation.etablissement } - { props.formation.nom }</h1>
@@ -197,57 +218,87 @@
<script>
export default {
// ========================================================================
// ÉTAT LOCAL du composant
// historique : tableau des données historiques 2020-2025
// chargementHistorique : true pendant la requête API historique
// ========================================================================
state: {
historique: [],
historique: [],
chargementHistorique: false
},
// Cycle de vie Riot : appelé une fois après l'insertion dans le DOM
// On lance les graphiques ET le chargement de l'historique en parallèle
onMounted() {
this.afficherGraphiques();
this.chargerHistorique();
this.chargerHistorique(); // appel API asynchrone (ne bloque pas le rendu)
},
// Cycle de vie Riot : appelé après chaque mise à jour des props ou de l'état
// Nécessaire car Charts.css construit le HTML des graphiques via innerHTML
onUpdated() {
this.afficherGraphiques();
// On attend d'avoir les données historiques avant de construire ces graphiques
if (this.state.historique.length > 0) {
this.afficherGraphiquesHistoriques();
}
},
// Retour à la liste des résultats
// Retour à la liste des résultats (délègue au parent via props.onback)
retourListe() {
this.props.onback();
},
// Limiter une valeur entre 0 et 1 pour Charts.css
// ========================================================================
// limiterValeur(val)
// RÔLE : convertir un pourcentage (0100) en proportion (01) pour Charts.css.
//
// Charts.css utilise la propriété CSS custom --size (entre 0 et 1)
// pour déterminer la hauteur/largeur d'une barre.
// Ex: 75% d'accès → --size: 0.75 → barre à 75% de hauteur
//
// On clamp la valeur entre 0 et 1 pour éviter les débordements.
// ========================================================================
limiterValeur(val) {
if (val === null || val === undefined || isNaN(val)) {
return 0;
return 0; // valeur manquante → barre à 0
}
var v = val / 100;
var v = val / 100; // conversion % → proportion
if (v > 1) {
return 1;
}
if (v < 0) {
return 0;
}
if (v > 1) { return 1; } // max : barre pleine
if (v < 0) { return 0; } // min : barre vide
return Math.round(v * 100) / 100;
return Math.round(v * 100) / 100; // arrondi à 2 décimales
},
// ========================================================================
// afficherGraphiques()
// RÔLE : construire et injecter les 3 graphiques Charts.css de la formation.
//
// Pourquoi innerHTML ? Charts.css nécessite une structure HTML <table> précise
// avec des CSS custom properties (--size, --color) sur chaque <td>.
// Ces tables sont générées dynamiquement et injectées dans les divs ref=...
//
// Les 3 graphiques :
// - graphBac : colonnes → répartition par type de bac des admis
// - graphMentions : colonnes → répartition par mention au bac des admis
// - graphProfil : barres horizontales → % femmes, boursiers, néo-bacs
// ========================================================================
afficherGraphiques() {
var f = this.props.formation;
if (!f) {
return;
return; // pas de formation chargée → rien à afficher
}
// this.$() est la méthode Riot pour sélectionner un élément dans le DOM du composant
var graphBac = this.$('[ref="graphBac"]');
var graphMentions = this.$('[ref="graphMentions"]');
var graphProfil = this.$('[ref="graphProfil"]');
// Graphique 1 : répartition par type de bac (colonnes verticales)
if (graphBac) {
graphBac.innerHTML = this.construireGraphiqueColonnes([
{ label: 'Général', valeur: f.pctGeneral, couleur: '#3d7fff' },
@@ -256,6 +307,7 @@
]);
}
// Graphique 2 : répartition par mention au bac (colonnes verticales)
if (graphMentions) {
graphMentions.innerHTML = this.construireGraphiqueColonnes([
{ label: 'Sans', valeur: f.pctSansMention, couleur: '#94a3b8' },
@@ -266,6 +318,7 @@
]);
}
// Graphique 3 : profil sociologique (barres horizontales)
if (graphProfil) {
graphProfil.innerHTML = this.construireGraphiqueBarres([
{ label: 'Femmes', valeur: f.pctFemmes, couleur: '#a78bfa' },
@@ -275,23 +328,34 @@
}
},
// ========================================================================
// chargerHistorique()
// RÔLE : charger les données 20202025 depuis api.js pour les graphiques
// d'évolution historique.
//
// Le code UAI est extrait de l'ID de la formation (format "CODUAI-NomFormation")
// On le passe à chargerHistoriqueFormation() qui fait 6 appels API en série.
// ========================================================================
async chargerHistorique() {
var f = this.props.formation;
if (!f) {
return;
}
// L'ID de la formation est construit comme "COD_UAI-NomFormation"
// On extrait la partie avant le premier tiret pour avoir le code UAI
var codUai = f.id.split('-')[0];
if (!codUai || !window.chargerHistoriqueFormation) {
return;
return; // pas de code UAI ou fonction non disponible
}
this.update({ chargementHistorique: true });
this.update({ chargementHistorique: true }); // affiche le spinner de chargement
try {
var historique = await window.chargerHistoriqueFormation(codUai, f.nom);
this.update({ historique: historique, chargementHistorique: false });
// onUpdated() est automatiquement appelé après update() → affichera les graphiques
} catch (e) {
console.error('Erreur chargement historique :', e);
this.update({ historique: [], chargementHistorique: false });
@@ -382,32 +446,65 @@
}
},
// ========================================================================
// construireGraphiqueColonnes(elements)
// RÔLE : générer le HTML d'un graphique en colonnes verticales (Charts.css).
//
// PARAMÈTRE : tableau d'objets { label, valeur, couleur }
// - label : étiquette affichée sous la colonne
// - valeur : pourcentage (0100) → converti en proportion par limiterValeur()
// - couleur : couleur CSS de la colonne (hex ou nom)
//
// FORMAT Charts.css :
// <table class="charts-css column ...">
// <tbody>
// <tr>
// <th scope="row">Général</th>
// <td style="--size: 0.75; --color: #3d7fff;"><span class="data">75%</span></td>
// </tr>
// </tbody>
// </table>
// ========================================================================
construireGraphiqueColonnes(elements) {
var lignes = '';
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
var taille = this.limiterValeur(el.valeur);
var affiche = el.valeur || 0;
var taille = this.limiterValeur(el.valeur); // proportion 01 pour --size
var affiche = el.valeur || 0; // valeur brute à afficher dans le tooltip
lignes += '<tr>';
lignes += '<th scope="row">' + el.label + '</th>';
lignes += '<td style="--size: ' + taille + '; --color: ' + el.couleur + ';">';
lignes += '<span class="data">' + affiche + '%</span>';
lignes += '<span class="data">' + affiche + '%</span>'; // affiché au survol
lignes += '</td></tr>';
}
// Classes Charts.css expliquées :
// column → graphique en colonnes verticales
// show-labels → affiche les étiquettes (th)
// show-primary-axis → affiche l'axe horizontal du bas
// show-4-secondary-axes → affiche 4 lignes de grille horizontales
// data-spacing-10 → espace de 10px entre les colonnes
return '<table class="charts-css column show-labels show-primary-axis show-4-secondary-axes data-spacing-10">'
+ '<thead><tr><th scope="col">Type</th><th scope="col">%</th></tr></thead>'
+ '<tbody>' + lignes + '</tbody></table>';
},
// ========================================================================
// construireGraphiqueBarres(elements)
// RÔLE : générer le HTML d'un graphique en barres horizontales (Charts.css).
//
// Identique à construireGraphiqueColonnes() mais avec class="charts-css bar"
// → les barres s'étendent horizontalement au lieu de verticalement.
// Utilisé pour le profil sociologique (femmes, boursiers, néo-bacs).
// ========================================================================
construireGraphiqueBarres(elements) {
var lignes = '';
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
var taille = this.limiterValeur(el.valeur);
var taille = this.limiterValeur(el.valeur); // proportion 01
var affiche = el.valeur || 0;
lignes += '<tr>';
@@ -417,6 +514,7 @@
lignes += '</td></tr>';
}
// "bar" à la place de "column" → barres horizontales
return '<table class="charts-css bar show-labels show-primary-axis show-4-secondary-axes data-spacing-14">'
+ '<thead><tr><th scope="col">Catégorie</th><th scope="col">%</th></tr></thead>'
+ '<tbody>' + lignes + '</tbody></table>';
+99 -21
View File
@@ -1,113 +1,191 @@
<map-view>
<!--
==========================================================================
COMPOSANT <map-view>
RÔLE : afficher une carte interactive Leaflet avec un marqueur par formation.
BIBLIOTHÈQUE : Leaflet.js (chargée dans index.html via CDN)
FOND DE CARTE : OpenStreetMap (open source, gratuit)
PROPS reçues depuis <app> :
- results : tableau de formations (celles avec latitude/longitude non null)
COMMUNICATION INTER-COMPOSANTS :
Ce composant expose window.mapFocus(id) pour permettre à <result-list>
de centrer la carte sur une formation via le bouton "Localiser".
(Les deux composants ne sont pas parent/enfant → on passe par window)
CYCLE DE VIE Riot utilisé :
- onMounted() → créer la carte et les marqueurs initiaux
- onUpdated() → rafraîchir les marqueurs quand les résultats changent
- onBeforeUnmount() → détruire la carte pour libérer la mémoire
==========================================================================
-->
<div class="map-box">
<h3>Carte des formations</h3>
<!-- Conteneur de la carte Leaflet (le ref="carte" permet d'y accéder via this.$()) -->
<div class="map" ref="carte"></div>
</div>
<script>
export default {
// ========================================================================
// onMounted() — Initialisation de la carte Leaflet
//
// Étapes :
// 1. Créer l'instance Leaflet attachée au div "ref=carte"
// 2. Créer un LayerGroup pour gérer les marqueurs en groupe
// 3. Ajouter le fond de carte OpenStreetMap
// 4. Afficher les marqueurs initiaux
// 5. Exposer window.mapFocus pour la communication avec result-list
// 6. Corriger le rendu de Leaflet (invalidateSize) après l'animation CSS
// ========================================================================
onMounted() {
var divCarte = this.$('div[ref="carte"]');
var divCarte = this.$('div[ref="carte"]'); // sélectionne le div de la carte
// Création de la carte Leaflet centrée sur la France (lat 46.8, lon 2.5), zoom 6
this.carte = L.map(divCarte).setView([46.8, 2.5], 6);
// LayerGroup : conteneur de marqueurs qui permet de tous les supprimer d'un coup
this.groupeMarqueurs = L.layerGroup().addTo(this.carte);
// Index id → marqueur : permet à centrerSurFormation() de retrouver rapidement un marqueur
this.marqueursIndex = {};
// Ajout du fond de carte OpenStreetMap (tuiles PNG)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(this.carte);
this.afficherMarqueurs();
this.afficherMarqueurs(); // premier affichage des marqueurs
var composant = this;
var composant = this; // capture pour les callbacks setTimeout
// invalidateSize() corrige un bug fréquent : si la carte s'anime à l'apparition,
// Leaflet ne connaît pas encore sa taille exacte et n'affiche pas les tuiles correctement.
// On l'appelle deux fois avec des délais différents pour être sûr.
setTimeout(function() {
if (composant.carte) {
composant.carte.invalidateSize();
}
if (composant.carte) { composant.carte.invalidateSize(); }
}, 200);
setTimeout(function() {
if (composant.carte) {
composant.carte.invalidateSize();
}
if (composant.carte) { composant.carte.invalidateSize(); }
}, 500);
// Exposition de mapFocus sur window pour permettre à result-list
// de centrer la carte sur une formation en cliquant "Localiser"
window.mapFocus = function(id) {
composant.centrerSurFormation(id);
};
},
// ========================================================================
// onUpdated() — Rafraîchissement de la carte quand les props changent
//
// Appelé par Riot chaque fois que le parent met à jour les résultats.
// On recharge tous les marqueurs et on corrige la taille de la carte.
// ========================================================================
onUpdated() {
this.afficherMarqueurs();
var composant = this;
if (this.carte) {
setTimeout(function() {
composant.carte.invalidateSize();
}, 100);
setTimeout(function() {
composant.carte.invalidateSize();
}, 300);
setTimeout(function() { composant.carte.invalidateSize(); }, 100);
setTimeout(function() { composant.carte.invalidateSize(); }, 300);
}
},
// ========================================================================
// onBeforeUnmount() — Nettoyage avant la suppression du composant
//
// On détruit l'instance Leaflet pour libérer les event listeners
// et éviter les memory leaks. On nettoie aussi window.mapFocus.
// ========================================================================
onBeforeUnmount() {
if (this.carte) {
this.carte.remove();
this.carte.remove(); // détruit la carte et libère la mémoire
this.carte = null;
}
window.mapFocus = null;
window.mapFocus = null; // nettoie la référence globale
},
// ========================================================================
// afficherMarqueurs()
// RÔLE : supprimer les anciens marqueurs et en créer de nouveaux pour
// chaque formation ayant des coordonnées GPS.
//
// Si des formations ont des coordonnées, on ajuste automatiquement le
// zoom de la carte pour les afficher toutes (fitBounds).
// Sinon, on revient à la vue par défaut (France entière).
// ========================================================================
afficherMarqueurs() {
if (!this.carte || !this.groupeMarqueurs) {
return;
}
this.groupeMarqueurs.clearLayers();
this.marqueursIndex = {};
this.groupeMarqueurs.clearLayers(); // supprime tous les marqueurs existants
this.marqueursIndex = {}; // réinitialise l'index
var coordonnees = [];
var coordonnees = []; // liste des coordonnées pour fitBounds
var formations = this.props.results || [];
for (var i = 0; i < formations.length; i++) {
var f = formations[i];
// On n'ajoute un marqueur que si la formation a des coordonnées GPS
if (f.latitude != null && f.longitude != null) {
var marqueur = L.marker([f.latitude, f.longitude]);
// Popup : fenêtre qui s'affiche au clic sur le marqueur
marqueur.bindPopup('<b>' + f.nom + '</b><br>' + f.ville);
marqueur.addTo(this.groupeMarqueurs);
// On indexe le marqueur par l'ID de la formation pour centrerSurFormation()
this.marqueursIndex[f.id] = marqueur;
coordonnees.push([f.latitude, f.longitude]);
}
}
// Ajuste le zoom pour montrer tous les marqueurs (avec 20px de marge)
if (coordonnees.length > 0) {
this.carte.fitBounds(coordonnees, { padding: [20, 20] });
} else {
// Aucun marqueur → on recentre sur la France
this.carte.setView([46.8, 2.5], 6);
}
},
// ========================================================================
// centrerSurFormation(id)
// RÔLE : centrer et zoomer la carte sur une formation spécifique.
// Appelé par window.mapFocus (depuis result-list via bouton "Localiser").
//
// Étapes :
// 1. Récupérer le marqueur depuis l'index
// 2. Scroller jusqu'à la carte (scrollIntoView)
// 3. Après 400ms (fin du scroll) : corriger la taille, zoomer, ouvrir le popup
// ========================================================================
centrerSurFormation(id) {
var marqueur = this.marqueursIndex[id];
if (marqueur && this.carte) {
var divCarte = this.$('div[ref="carte"]');
// Scroll vers la carte pour que l'utilisateur la voie avant le zoom
if (divCarte) {
divCarte.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
var composant = this;
// On attend la fin du scroll (~400ms) avant de zoomer sur le marqueur
setTimeout(function() {
composant.carte.invalidateSize();
// Zoom niveau 13 (ville) avec animation, puis ouverture du popup
composant.carte.setView(marqueur.getLatLng(), 13, { animate: true });
marqueur.openPopup();
}, 400);
+37 -4
View File
@@ -1,14 +1,36 @@
<result-list>
<!--
==========================================================================
COMPOSANT <result-list>
RÔLE : afficher la liste des formations retournées par l'API sous forme de cartes.
PROPS reçues depuis <app> :
- results : tableau de formations (objets creerFormation)
- hasSearched: boolean → true si l'utilisateur a lancé au moins une recherche
- loading : boolean → true pendant un appel API
- ondetail : callback(index) → ouvrir la fiche détail de la formation
- onselect : callback(index) → ajouter la formation au comparateur
Ce composant est "passif" : il ne fait aucun appel API, il affiche
uniquement les données transmises par le parent.
==========================================================================
-->
<div class="results">
<!-- Message "aucun résultat" : affiché si la recherche est terminée et vide -->
<div class="message" if={ props.results.length === 0 && props.hasSearched && !props.loading }>
Aucun résultat trouvé
</div>
<!-- Message de chargement : affiché pendant l'appel API -->
<div class="message" if={ props.loading }>
Chargement...
</div>
<!-- Boucle sur les formations : "each" est la directive de boucle de Riot -->
<!-- key={ formation.id } permet à Riot d'optimiser le rendu (évite les re-rendus inutiles) -->
<div each={ (formation, index) in props.results } key={ formation.id } class="card">
<h3>{ formation.nom }</h3>
<p><b>Établissement :</b> { formation.etablissement }</p>
@@ -16,8 +38,11 @@
<p><b>Filière :</b> { formation.filiere }</p>
<p><b>Taux d'accès :</b> { formation.tauxAcces }%</p>
<!-- bind(this, index) : passe l'index en argument au handler sans créer de closure -->
<button onclick={ afficherDetail.bind(this, index) }>Voir détail</button>
<button onclick={ ajouterALaSelection.bind(this, index) }>Ajouter à la sélection</button>
<!-- Bouton "Localiser" : affiché uniquement si la formation a des coordonnées GPS -->
<button onclick={ localiserSurCarte.bind(this, formation) } if={ formation.latitude != null }>Localiser</button>
</div>
@@ -26,20 +51,28 @@
<script>
export default {
// Déclencher l'affichage du détail d'une formation
// Demande au parent d'afficher la vue détail pour la formation à cet index
afficherDetail(index) {
this.props.ondetail(index);
},
// Ajouter une formation à la sélection
// Demande au parent d'ajouter la formation à la sélection du comparateur
ajouterALaSelection(index) {
this.props.onselect(index);
},
// Centrer la carte sur la formation
// ========================================================================
// localiserSurCarte(formation)
// RÔLE : centrer la carte Leaflet sur cette formation.
//
// Communication avec <map-view> via window.mapFocus :
// Les composants Riot ne peuvent pas communiquer directement entre eux
// (ils ne sont pas parent/enfant ici). On utilise donc une fonction globale
// window.mapFocus définie par <map-view> dans son onMounted.
// ========================================================================
localiserSurCarte(formation) {
if (window.mapFocus) {
window.mapFocus(formation.id);
window.mapFocus(formation.id); // <map-view> centre la carte sur ce marqueur
}
}
+31 -41
View File
@@ -1,4 +1,9 @@
<search-bar>
<!--
Barre de recherche + filtres avancés (toggle)
Communique avec le parent via props.onsearch(query, filters)
-->
<div class="search-bar">
<input
type="text"
@@ -10,6 +15,7 @@
<button onclick={ submitSearch }>Rechercher</button>
</div>
<!-- Bouton pour afficher/masquer les filtres avancés -->
<div class="filters-toggle">
<button class="btn btn-small btn-outline" onclick={ toggleFilters }>
{ state.labelFiltres }
@@ -18,6 +24,8 @@
<div class="filters-panel" if={ state.showFilters }>
<div class="filter-row">
<!-- Les valeurs correspondent exactement aux champs de l'API Parcoursup -->
<div class="filter-item">
<label>Type de formation</label>
<select onchange={ updateFiliere }>
@@ -74,7 +82,6 @@
<label>Taux d'accès min (%)</label>
<input type="number" min="0" max="100" value={ state.tauxMin } oninput={ updateTauxMin } placeholder="0" />
</div>
<div class="filter-item">
<label>Taux d'accès max (%)</label>
<input type="number" min="0" max="100" value={ state.tauxMax } oninput={ updateTauxMax } placeholder="100" />
@@ -84,62 +91,45 @@
<script>
export default {
state: {
query: '',
showFilters: false,
query: '',
showFilters: false,
labelFiltres: 'Filtres avancés',
filiere: '',
selectivite: '',
region: '',
tauxMin: 0,
tauxMax: 100
filiere: '',
selectivite: '',
region: '',
tauxMin: 0,
tauxMax: 100
},
updateQuery(e) {
this.update({ query: e.target.value });
},
updateQuery(e) { this.update({ query: e.target.value }); },
updateFiliere(e) { this.update({ filiere: e.target.value }); },
updateSelectivite(e) { this.update({ selectivite: e.target.value }); },
updateRegion(e) { this.update({ region: e.target.value }); },
updateTauxMin(e) { this.update({ tauxMin: Number(e.target.value) }); },
updateTauxMax(e) { this.update({ tauxMax: Number(e.target.value) }); },
// Déclenche la recherche avec la touche Entrée
handleKey(e) {
if (e.key === 'Enter') {
this.submitSearch();
}
if (e.key === 'Enter') { this.submitSearch(); }
},
// Affiche ou masque les filtres avancés
toggleFilters() {
var visible = !this.state.showFilters;
var label = visible ? 'Masquer les filtres' : 'Filtres avancés';
this.update({ showFilters: visible, labelFiltres: label });
},
updateFiliere(e) {
this.update({ filiere: e.target.value });
},
updateSelectivite(e) {
this.update({ selectivite: e.target.value });
},
updateRegion(e) {
this.update({ region: e.target.value });
},
updateTauxMin(e) {
this.update({ tauxMin: Number(e.target.value) });
},
updateTauxMax(e) {
this.update({ tauxMax: Number(e.target.value) });
this.update({ showFilters: visible, labelFiltres: visible ? 'Masquer les filtres' : 'Filtres avancés' });
},
// Collecte tous les filtres et appelle le callback du parent
submitSearch() {
var filters = {
filiere: this.state.filiere,
filiere: this.state.filiere,
selectivite: this.state.selectivite,
region: this.state.region,
tauxMin: this.state.tauxMin,
tauxMax: this.state.tauxMax
region: this.state.region,
tauxMin: this.state.tauxMin,
tauxMax: this.state.tauxMax
};
this.props.onsearch(this.state.query, filters);
}
};