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
+36 -60
View File
@@ -1,78 +1,58 @@
// Échapper les apostrophes dans les valeurs injectées dans la clause where
// ---------------------------------------------------------------
// Cette fonction est utilisée pour prévenir les problèmes de syntaxe
// quand on insère une valeur dans une requête Parcoursup qui nécessite
// des guillemets simples.
// =============================================================================
// api.js — Gestion des requêtes vers l'API publique Parcoursup (data.gouv.fr)
//
// Objectif pour la soutenance : Montrer qu'on sait forger dynamiquement une URL
// avec des filtres complexes et gérer des requêtes asynchrones (async/await)
// pour récupérer l'historique d'une formation sur plusieurs années.
// =============================================================================
// Échappe les apostrophes pour éviter de casser la syntaxe de la requête de type SQL
// Ex: "Licence d'histoire" -> "Licence d\'histoire"
function echapperValeur(valeur) {
// force en string (undefined/null -> "undefined"/"null"), puis remplace toutes
// les apostrophes par une version échappée (\').
return String(valeur).replace(/'/g, "\\'");
}
// Construire l'URL de requête vers l'API Parcoursup
// --------------------------------------------------
// - filtre par recherche texte + plusieurs paramètres de filtre
// - limit / offset pour pagination
// - where encodé (via encodeURIComponent)
// Construit l'URL avec les paramètres de pagination et la clause "where" contenant les filtres
export function construireURL(requete, limite = 20, decalage = 0, filtres = {}) {
var url = "https://data.enseignementsup-recherche.gouv.fr/api/explore/v2.1/catalog/datasets/fr-esr-parcoursup/records?";
// pagination simple
// Paramètres de base pour la pagination (limite de résultats et offset)
url += "limit=" + limite;
url += "&offset=" + decalage;
var conditions = [];
// recherche libre
// Recherche plein texte : utilise la commande "search" spécifique de l'API ODS (OpenDataSoft)
if (requete && requete.trim() !== "") {
conditions.push("search(lib_for_voe_ins, '" + echapperValeur(requete.trim()) + "')");
}
// ===== FILTRE FILIÈRE =====
// Ce bloc filtre les formations par type (BTS, BUT, Licence, CPGE, etc.)
// Il est OPTIONNEL : si l'user ne choisit pas de filière, on le saute.
// CONDITION : "Si l'user a choisi une filière ET qu'elle n'est pas vide"
// Filtrage par filière (BTS, BUT, Licence...)
if (filtres.filiere && filtres.filiere !== "") {
// if (filtres.filiere && ...)
// ↑ vérifie que filtres.filiere EXISTE (n'est pas undefined/null)
//
// if (... && filtres.filiere !== "")
// ↑ ET vérifie que la filière NE S'PAS VIDE (pas "")
//
// RÉSUMÉ : si les 2 conditions sont vraies, on rentre dans le bloc
// ACTION : construire et ajouter le filtre filière
conditions.push("fili='" + echapperValeur(filtres.filiere) + "'");
//
// Exemple si l'user choisit "BTS" :
// - filtres.filiere = "BTS"
// - echapperValeur("BTS") = "BTS" (pas d'apostrophe, inchangé)
// - condition.push ajoute : "fili='BTS'" au tableau conditions
// - Résultat : conditions = [..., "fili='BTS'"]
//
// Cette condition sera fusionnée avec les autres via AND
// Exemple d'URL finale : ...where=search(...) AND fili='BTS' AND region='IDF'
}
// Filtrage par sélectivité (sélective / non sélective)
if (filtres.selectivite && filtres.selectivite !== "") {
conditions.push("select_form='" + echapperValeur(filtres.selectivite) + "'");
}
// Filtrage par région géographique de l'établissement
if (filtres.region && filtres.region !== "") {
conditions.push("region_etab_aff='" + echapperValeur(filtres.region) + "'");
}
// Application de filtres numériques (taux d'accès minimum et maximum)
if (filtres.tauxMin && filtres.tauxMin > 0) {
conditions.push("taux_acces_ens>=" + filtres.tauxMin);
}
if (filtres.tauxMax && filtres.tauxMax < 100) {
conditions.push("taux_acces_ens<=" + filtres.tauxMax);
}
// si on a des conditions, on les ajoute dans paramètre where encodé
// Assemblage de toutes les conditions séparées par un opérateur ET logique
// On utilise encodeURIComponent pour assurer la validité de l'URL finale
if (conditions.length > 0) {
url += "&where=" + encodeURIComponent(conditions.join(" AND "));
}
@@ -80,55 +60,52 @@ export function construireURL(requete, limite = 20, decalage = 0, filtres = {})
return url;
}
// Charger les formations depuis l'API Parcoursup
// ----------------------------------------------
// Appelle l'URL construite ci-dessus via fetch, parse le JSON.
// Effectue la requête HTTP vers l'URL construite
// Fonction asynchrone qui retourne une promesse résolue avec les résultats JSON
export async function chargerFormations(requete, limite = 20, decalage = 0, filtres = {}) {
var url = construireURL(requete, limite, decalage, filtres);
var reponse = await fetch(url);
// vérifie le code HTTP; si erreur, lève une exception pour le remonté à l'appelant
// Gestion des erreurs HTTP (ex: 400 Bad Request, 500 Internal Server Error)
if (!reponse.ok) {
throw new Error("Erreur HTTP " + reponse.status);
}
// Extraction et retour du corps de la réponse au format JSON
return await reponse.json();
}
// Charger l'historique d'une formation sur plusieurs années
// ---------------------------------------------------------
// - appelle plusieurs versions du dataset (2020..2025)
// - récupère le premier résultat valide pour chaque année
// - calcule un taux d'accès (acc_tot / voe_tot)
// Fonction métier complexe : reconstitue l'historique d'une formation sur plusieurs années
// Démontre l'orchestration de multiples appels API asynchrones
export async function chargerHistoriqueFormation(codUai, nomFormation) {
// Table de correspondance entre l'année et le nom du dataset sur open data
var jeuDeDonnees = {
2020: "fr-esr-parcoursup_2020",
2021: "fr-esr-parcoursup_2021",
2022: "fr-esr-parcoursup_2022",
2023: "fr-esr-parcoursup_2023",
2024: "fr-esr-parcoursup_2024",
2025: "fr-esr-parcoursup"
2025: "fr-esr-parcoursup" // L'année en cours est sur le dataset par défaut
};
var historique = [];
// on échappe aussi cod_uai et nom pour la requête (sécurité sintaxe)
var nomCourt = echapperValeur((nomFormation || "").substring(0, 40));
var codeUai = echapperValeur(codUai);
var annees = [2020, 2021, 2022, 2023, 2024, 2025];
// Requête itérative pour consolider les données par année
for (var i = 0; i < annees.length; i++) {
var annee = annees[i];
var dataset = jeuDeDonnees[annee];
try {
// On croise le code établissement (cod_uai) avec le nom de formation pour être précis
var where = "cod_uai='" + codeUai + "' AND search(lib_for_voe_ins, '" + nomCourt + "')";
var where =
"cod_uai='" + codeUai + "' AND search(lib_for_voe_ins, '" + nomCourt + "')";
// On limite à 5 résultats max et on sélectionne uniquement les colonnes nécessaires (optimisation)
var url = "https://data.enseignementsup-recherche.gouv.fr/api/explore/v2.1/catalog/datasets/"
+ dataset + "/records?"
+ "limit=5"
@@ -138,19 +115,18 @@ export async function chargerHistoriqueFormation(codUai, nomFormation) {
var reponse = await fetch(url);
if (reponse.ok) {
var donnees = await reponse.json();
if (donnees.results && donnees.results.length > 0) {
var ligne = donnees.results[0];
var ligne = donnees.results[0]; // On prend le premier résultat trouvé
var taux = 0;
// Calcul mathématique propre du taux d'accès pour éviter une division par zéro
if (ligne.voe_tot && ligne.voe_tot > 0) {
taux = Math.round((ligne.acc_tot / ligne.voe_tot) * 100);
}
// ajoute un objet historique pour cette année
// Formatage d'un objet normalisé pour le graphique Riot
historique.push({
annee: annee,
tauxAcces: taux,
@@ -169,8 +145,8 @@ export async function chargerHistoriqueFormation(codUai, nomFormation) {
}
} catch (e) {
// si un appel échoue, on affiche l'erreur et on continue la boucle
console.warn("Erreur pour l'année " + annee + " :", e);
// Le bloc try/catch empêche l'échec de la boucle entière si une seule année est en erreur (ex: dataset absent)
console.warn("L'année " + annee + " n'a pas pu être récupérée :", e);
}
}
+47 -122
View File
@@ -1,4 +1,9 @@
<app>
<!--
Composant racine — routeur SPA basé sur les ancres URL (#/)
3 vues : 'search' | 'detail' | 'comparateur'
-->
<header class="site-header">
<div class="header-inner">
<a class="logo" href="#/">
@@ -6,6 +11,7 @@
<span class="logo-text">Parcoursup <span class="logo-light">Explorer</span></span>
</a>
<div class="header-right">
<!-- Badge cliquable vers le comparateur, visible si au moins 1 formation sélectionnée -->
<a href="#/comparateur" class="header-badge badge-clickable" if={ state.selectedFormations.length > 0 }>
{ state.selectedFormations.length } sélection(s)
</a>
@@ -16,7 +22,7 @@
<div class="page">
<!-- ============== VUE RECHERCHE ============== -->
<!-- VUE RECHERCHE -->
<div if={ state.view === 'search' }>
<search-bar onsearch={ lancerRecherche }></search-bar>
@@ -36,6 +42,7 @@
onselect={ ajouterSelection }>
</result-list>
<!-- Pagination (visible si plus de résultats que la limite) -->
<div class="pagination" if={ state.total > state.limit }>
<button class="btn btn-outline" onclick={ pagePrecedente } disabled={ state.page === 1 }>
← Précédent
@@ -49,35 +56,30 @@
</div>
</div>
<!-- ============== VUE DÉTAIL ============== -->
<!-- VUE DÉTAIL -->
<div if={ state.view === 'detail' && state.selected }>
<detail-view
formation={ state.selected }
onback={ retourRecherche }>
</detail-view>
<detail-view formation={ state.selected } onback={ retourRecherche }></detail-view>
</div>
<div if={ state.view === 'detail' && !state.selected } class="message">
Chargement de la formation...
</div>
<!-- ============== VUE COMPARATEUR ============== -->
<!-- VUE COMPARATEUR -->
<div if={ state.view === 'comparateur' }>
<button class="btn btn-outline" onclick={ retourRecherche } style="margin-bottom: 16px;">← Retour à la recherche</button>
<comparateur-view
formations={ state.selectedFormations }
onretirer={ retirerSelection }
onvider={ viderSelection }>
</comparateur-view>
</div>
</div>
<script>
export default {
state: {
view: 'search',
loading: false,
@@ -97,21 +99,17 @@
var saved = localStorage.getItem('selectionFormations');
var soi = this;
// Charger la sélection locale si elle existe
// Restaurer la sélection depuis le localStorage
if (saved) {
try {
this.state.selectedFormations = JSON.parse(saved);
} catch (e) {
console.error('Erreur lecture localStorage :', e);
}
try { this.state.selectedFormations = JSON.parse(saved); }
catch (e) { console.error('Erreur localStorage :', e); }
}
// Écouter les changements de connexion Firebase
// Écouter les changements de session Firebase
window.firebaseServices.onUserChanged(function(user) {
soi.update({ user: user });
// Si un utilisateur vient de se connecter, charger sa sélection depuis Firestore
// À la connexion, on charge la sélection depuis Firestore
if (user) {
window.firebaseServices.loadUserData(user.uid)
.then(function(donnees) {
@@ -120,47 +118,34 @@
localStorage.setItem('selectionFormations', JSON.stringify(donnees.selection));
}
})
.catch(function(err) {
console.error('Erreur chargement Firestore :', err);
});
.catch(function(err) { console.error('Erreur Firestore :', err); });
}
});
window.addEventListener('hashchange', function() {
soi.gererRoute();
});
// Écouter les changements d'URL pour le routage
window.addEventListener('hashchange', function() { soi.gererRoute(); });
this.gererRoute();
},
// Appelée après une connexion ou inscription réussie
surConnexion() {
// L'écouteur onUserChanged dans onMounted gère automatiquement le reste
// Géré automatiquement par onUserChanged ci-dessus
},
// Appelée après une déconnexion
surDeconnexion() {
// On vide la sélection : l'utilisateur doit se reconnecter pour retrouver ses formations
this.update({ selectedFormations: [] });
localStorage.removeItem('selectionFormations');
},
// Sauvegarder la sélection en local ET dans Firestore si connecté
// Sauvegarde la sélection en local ET dans Firestore si connecté
async sauvegarderSelection(selection) {
localStorage.setItem('selectionFormations', JSON.stringify(selection));
if (this.state.user) {
try {
await window.firebaseServices.saveUserData(this.state.user.uid, { selection: selection });
} catch (err) {
console.error('Erreur sauvegarde Firestore :', err);
try { await window.firebaseServices.saveUserData(this.state.user.uid, { selection }); }
catch (err) { console.error('Erreur sauvegarde :', err); }
}
}
},
// Lit l'ancre URL et affiche la vue correspondante
gererRoute() {
var ancre = window.location.hash || '#/';
var chemin = ancre.slice(1) || '/';
@@ -176,44 +161,32 @@
}
},
// Cherche la formation dans le cache (résultats / sélection) avant de faire un appel API
async chargerFormationParId(id) {
var i;
for (i = 0; i < this.state.results.length; i++) {
if (this.state.results[i].id === id) {
this.update({ selected: this.state.results[i] });
return;
}
if (this.state.results[i].id === id) { this.update({ selected: this.state.results[i] }); return; }
}
for (i = 0; i < this.state.selectedFormations.length; i++) {
if (this.state.selectedFormations[i].id === id) {
this.update({ selected: this.state.selectedFormations[i] });
return;
}
if (this.state.selectedFormations[i].id === id) { this.update({ selected: this.state.selectedFormations[i] }); return; }
}
// En dernier recours : appel API avec un mot-clé extrait de l'ID
try {
var parties = id.split('-');
var motCle = parties.slice(1).join(' ').substring(0, 30);
var motCle = id.split('-').slice(1).join(' ').substring(0, 30);
if (motCle) {
var donnees = await window.chargerFormations(motCle, 10, 0);
if (donnees.results && donnees.results.length > 0) {
for (i = 0; i < donnees.results.length; i++) {
var formation = window.creerFormation(donnees.results[i]);
if (formation.id === id) {
this.update({ selected: formation });
return;
}
var f = window.creerFormation(donnees.results[i]);
if (f.id === id) { this.update({ selected: f }); return; }
}
this.update({ selected: window.creerFormation(donnees.results[0]) });
}
}
} catch (erreur) {
console.error('Erreur chargement formation :', erreur);
}
} catch (erreur) { console.error('Erreur chargement formation :', erreur); }
},
afficherDetail(index) {
@@ -227,61 +200,29 @@
},
async lancerRecherche(requete, filtres) {
this.update({
loading: true,
hasSearched: true,
selected: null,
view: 'search',
query: requete,
filters: filtres || {},
page: 1
});
this.update({ loading: true, hasSearched: true, selected: null, view: 'search', query: requete, filters: filtres || {}, page: 1 });
window.location.hash = '#/';
await this.chargerPage(1);
},
// Charge la page demandée en calculant l'offset (décalage) correspondant
async chargerPage(page) {
this.update({ loading: true });
try {
var decalage = (page - 1) * this.state.limit;
var donnees = await window.chargerFormations(
this.state.query,
this.state.limit,
decalage,
this.state.filters
);
var donnees = await window.chargerFormations(this.state.query, this.state.limit, decalage, this.state.filters);
var formations = [];
if (donnees.results) {
for (var i = 0; i < donnees.results.length; i++) {
var brut = donnees.results[i];
formations.push(window.creerFormation(brut));
formations.push(window.creerFormation(donnees.results[i]));
}
}
var total = 0;
if (donnees.total_count) {
total = donnees.total_count;
}
this.update({
results: formations,
total: total,
page: page,
loading: false
});
this.update({ results: formations, total: donnees.total_count || 0, page, loading: false });
} catch (erreur) {
console.error(erreur);
this.update({
results: [],
total: 0,
loading: false
});
this.update({ results: [], total: 0, loading: false });
}
},
@@ -290,48 +231,32 @@
},
async pageSuivante() {
if (this.state.page < this.nombreTotalPages()) {
await this.chargerPage(this.state.page + 1);
}
if (this.state.page < this.nombreTotalPages()) { await this.chargerPage(this.state.page + 1); }
},
async pagePrecedente() {
if (this.state.page > 1) {
await this.chargerPage(this.state.page - 1);
}
if (this.state.page > 1) { await this.chargerPage(this.state.page - 1); }
},
// Ajoute la formation au comparateur si elle n'y est pas déjà
ajouterSelection(index) {
var formation = this.state.results[index];
var selection = this.state.selectedFormations.slice();
var dejaAjout = false;
for (var i = 0; i < selection.length; i++) {
if (selection[i].id === formation.id) {
dejaAjout = true;
}
if (selection[i].id === formation.id) { return; } // doublon
}
if (!dejaAjout) {
selection.push(formation);
this.update({ selectedFormations: selection });
this.sauvegarderSelection(selection);
}
},
// Retire la formation avec cet id de la sélection
retirerSelection(id) {
var nouvelleSelection = [];
for (var i = 0; i < this.state.selectedFormations.length; i++) {
var f = this.state.selectedFormations[i];
if (f.id !== id) {
nouvelleSelection.push(f);
}
}
this.update({ selectedFormations: nouvelleSelection });
this.sauvegarderSelection(nouvelleSelection);
var nouvelle = this.state.selectedFormations.filter(function(f) { return f.id !== id; });
this.update({ selectedFormations: nouvelle });
this.sauvegarderSelection(nouvelle);
},
viderSelection() {
+72 -11
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,6 +96,14 @@
<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',
@@ -82,21 +115,26 @@
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;
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);
+111 -42
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 { 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 { 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,11 +262,18 @@
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 = '';
// 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'; }
+117 -19
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: [],
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
}
}
+20 -30
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,6 +91,7 @@
<script>
export default {
state: {
query: '',
showFilters: false,
@@ -95,42 +103,25 @@
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,
@@ -139,7 +130,6 @@
tauxMin: this.state.tauxMin,
tauxMax: this.state.tauxMax
};
this.props.onsearch(this.state.query, filters);
}
};
+26 -25
View File
@@ -1,3 +1,11 @@
// =============================================================================
// firebase.js — Gestion de la base de données et de l'authentification Firebase
//
// Objectif pour la soutenance : Montrer l'intégration d'un Backend-as-a-Service,
// l'utilisation de modules ES externes et la séparation entre les accès BDD
// (Firestore) et la gestion des comptes (Auth).
// =============================================================================
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import {
@@ -16,6 +24,8 @@ import {
} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// Clés publiques. La sécurité réelle des données se configure côté Firebase
// grâce aux Firestore Security Rules (qui empêcheraient un uid de lire la data d'un autre uid).
const firebaseConfig = {
apiKey: "AIzaSyDr1jMgGm0Oj_bOiWY-8Gy27IlzkmAzlOM",
authDomain: "parcoursupp-expl.firebaseapp.com",
@@ -25,56 +35,47 @@ const firebaseConfig = {
appId: "1:973054617217:web:4d52af4280396976228f80"
};
// Initialisation des services uniques pour toute l'app
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const auth = getAuth(app); // Service dédié à l'authentification
const db = getFirestore(app); // Service base de données
// Créer un compte avec email et mot de passe
// --- Services d'authentification ---
// Encapsulation des méthodes Firebase pour des appels asynchrones simplifiés dans nos composants
async function createAccount(email, password) {
return createUserWithEmailAndPassword(auth, email, password);
}
// Se connecter avec email et mot de passe
async function login(email, password) {
return signInWithEmailAndPassword(auth, email, password);
}
// Se déconnecter
async function logout() {
return signOut(auth);
}
// Écouter les changements d'état de connexion
// Le pattern de listener (callback) : permet de réagir à tout changement de session
// sans avoir à vérifier en permanence l'état de l'utilisateur.
function onUserChanged(callback) {
return onAuthStateChanged(auth, callback);
}
// Sauvegarder les données d'un utilisateur dans Firestore
// --- Services base de données Firestore ---
// Utilise { merge: true } pour ne mettre à jour que le champ `selection`
// sans écraser d'éventuelles autres données de l'utilisateur si on complexifie l'app plus tard.
async function saveUserData(uid, data) {
await setDoc(doc(db, "users", uid), data, { merge: true });
}
// Charger les données d'un utilisateur depuis Firestore
// Récupère l'état enregistré au format JSON document.
// S'il n'existe pas, on retourne poliment null.
async function loadUserData(uid) {
var snap = await getDoc(doc(db, "users", uid));
if (snap.exists()) {
return snap.data();
} else {
return null;
}
return snap.exists() ? snap.data() : null;
}
export {
auth,
db,
createAccount,
login,
logout,
onUserChanged,
saveUserData,
loadUserData
};
export { auth, db, createAccount, login, logout, onUserChanged, saveUserData, loadUserData };
+31 -11
View File
@@ -1,22 +1,35 @@
// Créer un objet formation à partir des données brutes de l'API
// =============================================================================
// formation.js — Couche de mapping et de normalisation
//
// Objectif pour la soutenance : Démontrer le Design Pattern "Adapter" / "DTO".
// Les données brutes de l'API ont des noms de champs très abrégés et abscons.
// Ce ficher permet de tout transformer avec des noms de variables explicites
// pour faciliter le travail et éviter la dette technique dans les composants UI.
// =============================================================================
export function creerFormation(brut) {
// Calcul systématique du taux d'accès : pourcentage = (admis / candidats) * 100
var taux = 0;
var latitude = null;
var longitude = null;
if (brut.voe_tot && brut.voe_tot > 0) {
taux = Math.round((brut.acc_tot / brut.voe_tot) * 100);
}
// Les coordonnées géographiques peuvent être absentes pour certaines formations.
// On s'en prémunit en les déclarant explicitement comme `null` le cas échéant.
var latitude = null;
var longitude = null;
if (brut.g_olocalisation_des_formations) {
latitude = brut.g_olocalisation_des_formations.lat;
longitude = brut.g_olocalisation_des_formations.lon;
}
return {
// Création d'une clé d'identification unique requise par notre application
// pour retrouver ou différencier deux éléments (ex: pour la carte ou la sélection)
id: brut.cod_uai + "-" + brut.lib_for_voe_ins,
// Métadonnées administratives et géographiques de l'établissement
nom: brut.lib_for_voe_ins,
etablissement: brut.g_ea_lib_vx,
ville: brut.ville_etab,
@@ -26,40 +39,47 @@ export function creerFormation(brut) {
academie: brut.acad_mies,
contrat: brut.contrat_etab,
// Cursus et niveau
filiere: brut.fili,
selectivite: brut.select_form,
// Statistiques principales d'admission
capacite: brut.capa_fin,
candidats: brut.voe_tot,
admis: brut.acc_tot,
tauxAcces: taux,
latitude: latitude,
longitude: longitude,
// Placement sur la carte
latitude,
longitude,
// Indicateurs du profil étudiant entrant (sociologique)
pctFemmes: brut.pct_f,
pctBoursiers: brut.pct_bours,
pctNeoBac: brut.pct_neobac,
// Origine académique par type du Bac précédent
pctGeneral: brut.pct_bg,
pctTechno: brut.pct_bt,
pctPro: brut.pct_bp,
// Niveaux scolaires par les mentions obtenues
pctSansMention: brut.pct_sansmention,
pctAB: brut.pct_ab,
pctB: brut.pct_b,
pctTB: brut.pct_tb,
pctTBF: brut.pct_tbf,
pctDebutPhase: brut.pct_acc_debutpp,
pctDateBac: brut.pct_acc_datebac,
pctFinPhase: brut.pct_acc_finpp,
// Dynamique temporelle : comment la formation s'est remplie durant la procédure
pctDebutPhase: brut.pct_acc_debutpp, // 30 mai (lancement)
pctDateBac: brut.pct_acc_datebac, // 16 juin (résultats)
pctFinPhase: brut.pct_acc_finpp, // 11 juillet
admisDebutPhase: brut.acc_debutpp,
admisDateBac: brut.acc_datebac,
admisFinPhase: brut.acc_finpp,
// Phase principale
// Détails de la Phase Principale — Les effectifs découpés par filière d'origine
voePPGeneral: brut.nb_voe_pp_bg,
voePPTechno: brut.nb_voe_pp_bt,
voePPPro: brut.nb_voe_pp_bp,
@@ -84,7 +104,7 @@ export function creerFormation(brut) {
acceptesPPAutres: brut.acc_at,
acceptesPPTotal: brut.acc_pp,
// Phase complémentaire
// Détails de la Phase Complémentaire — Session de rattrapage
voePCGeneral: brut.nb_voe_pc_bg,
voePCTechno: brut.nb_voe_pc_bt,
voePCPro: brut.nb_voe_pc_bp,
+35 -24
View File
@@ -1,20 +1,34 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Parcoursup Explorer</title>
<!-- Inclusion des feuilles de style pour l'application, la carte (Leaflet) et les graphiques (Charts.css) -->
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/charts.css/dist/charts.min.css" />
<!-- Inclusion de Riot.js pour la gestion des composants et de Leaflet pour la cartographie -->
<script src="https://cdn.jsdelivr.net/npm/riot@9/riot+compiler.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
<body>
</head>
<body>
<!-- Point de montage de l'application : le composant <app> va y être instancié -->
<app></app>
<!--
IMPORT DES COMPOSANTS RIOT (Les "briques" de notre application)
L'application est découpée en petits morceaux réutilisables (composants).
L'attribut type="riot" indique au compilateur Riot.js de transformer
ce code spécial (.riot) en vrai JavaScript compréhensible par le navigateur.
L'ordre est ici très important : on charge d'abord les enfants,
et on termine toujours par le composant principal (app.riot).
-->
<script src="./components/search-bar.riot" type="riot"></script>
<script src="./components/result-list.riot" type="riot"></script>
<script src="./components/detail-view.riot" type="riot"></script>
@@ -23,37 +37,34 @@
<script src="./components/comparateur.riot" type="riot"></script>
<script src="./app.riot" type="riot"></script>
<!--
SCRIPT D'INITIALISATION
L'attribut type="module" est obligatoire en HTML moderne dès qu'on
découpe son code JavaScript dans plusieurs fichiers.
Cela nous permet d'utiliser "import" pour récupérer les fonctions de notre API,
du Firebase et de notre algorithme depuis les fichiers séparés.
-->
<script type="module">
import { chargerFormations, chargerHistoriqueFormation } from './api.js'
import { creerFormation } from './formation.js'
import {
auth,
db,
createAccount,
login,
logout,
onUserChanged,
saveUserData,
loadUserData
} from './firebase.js'
import { auth, db, createAccount, login, logout, onUserChanged, saveUserData, loadUserData } from './firebase.js'
// =========================================================================
// EXPOSITION GLOBALE
// Les composants Riot (.riot) ne peuvent pas faire de "import" par eux-mêmes.
// C'est pourquoi, une fois les fonctions importées ici, on les attache
// à l'objet global `window` pour qu'elles soient accessibles partout.
// =========================================================================
window.chargerFormations = chargerFormations
window.creerFormation = creerFormation
window.chargerHistoriqueFormation = chargerHistoriqueFormation
window.firebaseServices = {
auth,
db,
createAccount,
login,
logout,
onUserChanged,
saveUserData,
loadUserData
}
window.firebaseServices = { auth, db, createAccount, login, logout, onUserChanged, saveUserData, loadUserData }
// Ordre des opérations : 1. Compilation des composants 2. Montage du composant principal
await riot.compile()
riot.mount('app')
</script>
</body>
</body>
</html>