mAj
This commit is contained in:
+36
-60
@@ -1,78 +1,58 @@
|
|||||||
// Échapper les apostrophes dans les valeurs injectées dans la clause where
|
// =============================================================================
|
||||||
// ---------------------------------------------------------------
|
// api.js — Gestion des requêtes vers l'API publique Parcoursup (data.gouv.fr)
|
||||||
// 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
|
// Objectif pour la soutenance : Montrer qu'on sait forger dynamiquement une URL
|
||||||
// des guillemets simples.
|
// 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) {
|
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, "\\'");
|
return String(valeur).replace(/'/g, "\\'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire l'URL de requête vers l'API Parcoursup
|
// Construit l'URL avec les paramètres de pagination et la clause "where" contenant les filtres
|
||||||
// --------------------------------------------------
|
|
||||||
// - filtre par recherche texte + plusieurs paramètres de filtre
|
|
||||||
// - limit / offset pour pagination
|
|
||||||
// - where encodé (via encodeURIComponent)
|
|
||||||
export function construireURL(requete, limite = 20, decalage = 0, 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?";
|
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 += "limit=" + limite;
|
||||||
url += "&offset=" + decalage;
|
url += "&offset=" + decalage;
|
||||||
|
|
||||||
var conditions = [];
|
var conditions = [];
|
||||||
|
|
||||||
// recherche libre
|
// Recherche plein texte : utilise la commande "search" spécifique de l'API ODS (OpenDataSoft)
|
||||||
if (requete && requete.trim() !== "") {
|
if (requete && requete.trim() !== "") {
|
||||||
conditions.push("search(lib_for_voe_ins, '" + echapperValeur(requete.trim()) + "')");
|
conditions.push("search(lib_for_voe_ins, '" + echapperValeur(requete.trim()) + "')");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== FILTRE FILIÈRE =====
|
// Filtrage par filière (BTS, BUT, Licence...)
|
||||||
// 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"
|
|
||||||
if (filtres.filiere && filtres.filiere !== "") {
|
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) + "'");
|
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 !== "") {
|
if (filtres.selectivite && filtres.selectivite !== "") {
|
||||||
conditions.push("select_form='" + echapperValeur(filtres.selectivite) + "'");
|
conditions.push("select_form='" + echapperValeur(filtres.selectivite) + "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtrage par région géographique de l'établissement
|
||||||
if (filtres.region && filtres.region !== "") {
|
if (filtres.region && filtres.region !== "") {
|
||||||
conditions.push("region_etab_aff='" + echapperValeur(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) {
|
if (filtres.tauxMin && filtres.tauxMin > 0) {
|
||||||
conditions.push("taux_acces_ens>=" + filtres.tauxMin);
|
conditions.push("taux_acces_ens>=" + filtres.tauxMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtres.tauxMax && filtres.tauxMax < 100) {
|
if (filtres.tauxMax && filtres.tauxMax < 100) {
|
||||||
conditions.push("taux_acces_ens<=" + filtres.tauxMax);
|
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) {
|
if (conditions.length > 0) {
|
||||||
url += "&where=" + encodeURIComponent(conditions.join(" AND "));
|
url += "&where=" + encodeURIComponent(conditions.join(" AND "));
|
||||||
}
|
}
|
||||||
@@ -80,55 +60,52 @@ export function construireURL(requete, limite = 20, decalage = 0, filtres = {})
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger les formations depuis l'API Parcoursup
|
// Effectue la requête HTTP vers l'URL construite
|
||||||
// ----------------------------------------------
|
// Fonction asynchrone qui retourne une promesse résolue avec les résultats JSON
|
||||||
// Appelle l'URL construite ci-dessus via fetch, parse le JSON.
|
|
||||||
export async function chargerFormations(requete, limite = 20, decalage = 0, filtres = {}) {
|
export async function chargerFormations(requete, limite = 20, decalage = 0, filtres = {}) {
|
||||||
|
|
||||||
var url = construireURL(requete, limite, decalage, filtres);
|
var url = construireURL(requete, limite, decalage, filtres);
|
||||||
var reponse = await fetch(url);
|
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) {
|
if (!reponse.ok) {
|
||||||
throw new Error("Erreur HTTP " + reponse.status);
|
throw new Error("Erreur HTTP " + reponse.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extraction et retour du corps de la réponse au format JSON
|
||||||
return await reponse.json();
|
return await reponse.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger l'historique d'une formation sur plusieurs années
|
// Fonction métier complexe : reconstitue l'historique d'une formation sur plusieurs années
|
||||||
// ---------------------------------------------------------
|
// Démontre l'orchestration de multiples appels API asynchrones
|
||||||
// - 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)
|
|
||||||
export async function chargerHistoriqueFormation(codUai, nomFormation) {
|
export async function chargerHistoriqueFormation(codUai, nomFormation) {
|
||||||
|
|
||||||
|
// Table de correspondance entre l'année et le nom du dataset sur open data
|
||||||
var jeuDeDonnees = {
|
var jeuDeDonnees = {
|
||||||
2020: "fr-esr-parcoursup_2020",
|
2020: "fr-esr-parcoursup_2020",
|
||||||
2021: "fr-esr-parcoursup_2021",
|
2021: "fr-esr-parcoursup_2021",
|
||||||
2022: "fr-esr-parcoursup_2022",
|
2022: "fr-esr-parcoursup_2022",
|
||||||
2023: "fr-esr-parcoursup_2023",
|
2023: "fr-esr-parcoursup_2023",
|
||||||
2024: "fr-esr-parcoursup_2024",
|
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 = [];
|
var historique = [];
|
||||||
|
|
||||||
// on échappe aussi cod_uai et nom pour la requête (sécurité sintaxe)
|
|
||||||
var nomCourt = echapperValeur((nomFormation || "").substring(0, 40));
|
var nomCourt = echapperValeur((nomFormation || "").substring(0, 40));
|
||||||
var codeUai = echapperValeur(codUai);
|
var codeUai = echapperValeur(codUai);
|
||||||
var annees = [2020, 2021, 2022, 2023, 2024, 2025];
|
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++) {
|
for (var i = 0; i < annees.length; i++) {
|
||||||
|
|
||||||
var annee = annees[i];
|
var annee = annees[i];
|
||||||
var dataset = jeuDeDonnees[annee];
|
var dataset = jeuDeDonnees[annee];
|
||||||
|
|
||||||
try {
|
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 =
|
// On limite à 5 résultats max et on sélectionne uniquement les colonnes nécessaires (optimisation)
|
||||||
"cod_uai='" + codeUai + "' AND search(lib_for_voe_ins, '" + nomCourt + "')";
|
|
||||||
|
|
||||||
var url = "https://data.enseignementsup-recherche.gouv.fr/api/explore/v2.1/catalog/datasets/"
|
var url = "https://data.enseignementsup-recherche.gouv.fr/api/explore/v2.1/catalog/datasets/"
|
||||||
+ dataset + "/records?"
|
+ dataset + "/records?"
|
||||||
+ "limit=5"
|
+ "limit=5"
|
||||||
@@ -138,19 +115,18 @@ export async function chargerHistoriqueFormation(codUai, nomFormation) {
|
|||||||
var reponse = await fetch(url);
|
var reponse = await fetch(url);
|
||||||
|
|
||||||
if (reponse.ok) {
|
if (reponse.ok) {
|
||||||
|
|
||||||
var donnees = await reponse.json();
|
var donnees = await reponse.json();
|
||||||
|
|
||||||
if (donnees.results && donnees.results.length > 0) {
|
if (donnees.results && donnees.results.length > 0) {
|
||||||
|
var ligne = donnees.results[0]; // On prend le premier résultat trouvé
|
||||||
var ligne = donnees.results[0];
|
|
||||||
var taux = 0;
|
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) {
|
if (ligne.voe_tot && ligne.voe_tot > 0) {
|
||||||
taux = Math.round((ligne.acc_tot / ligne.voe_tot) * 100);
|
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({
|
historique.push({
|
||||||
annee: annee,
|
annee: annee,
|
||||||
tauxAcces: taux,
|
tauxAcces: taux,
|
||||||
@@ -169,8 +145,8 @@ export async function chargerHistoriqueFormation(codUai, nomFormation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// si un appel échoue, on affiche l'erreur et on continue la boucle
|
// 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("Erreur pour l'année " + annee + " :", e);
|
console.warn("L'année " + annee + " n'a pas pu être récupérée :", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-122
@@ -1,4 +1,9 @@
|
|||||||
<app>
|
<app>
|
||||||
|
<!--
|
||||||
|
Composant racine — routeur SPA basé sur les ancres URL (#/)
|
||||||
|
3 vues : 'search' | 'detail' | 'comparateur'
|
||||||
|
-->
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a class="logo" href="#/">
|
<a class="logo" href="#/">
|
||||||
@@ -6,6 +11,7 @@
|
|||||||
<span class="logo-text">Parcoursup <span class="logo-light">Explorer</span></span>
|
<span class="logo-text">Parcoursup <span class="logo-light">Explorer</span></span>
|
||||||
</a>
|
</a>
|
||||||
<div class="header-right">
|
<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 }>
|
<a href="#/comparateur" class="header-badge badge-clickable" if={ state.selectedFormations.length > 0 }>
|
||||||
{ state.selectedFormations.length } sélection(s)
|
{ state.selectedFormations.length } sélection(s)
|
||||||
</a>
|
</a>
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
||||||
<!-- ============== VUE RECHERCHE ============== -->
|
<!-- VUE RECHERCHE -->
|
||||||
<div if={ state.view === 'search' }>
|
<div if={ state.view === 'search' }>
|
||||||
<search-bar onsearch={ lancerRecherche }></search-bar>
|
<search-bar onsearch={ lancerRecherche }></search-bar>
|
||||||
|
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
onselect={ ajouterSelection }>
|
onselect={ ajouterSelection }>
|
||||||
</result-list>
|
</result-list>
|
||||||
|
|
||||||
|
<!-- Pagination (visible si plus de résultats que la limite) -->
|
||||||
<div class="pagination" if={ state.total > state.limit }>
|
<div class="pagination" if={ state.total > state.limit }>
|
||||||
<button class="btn btn-outline" onclick={ pagePrecedente } disabled={ state.page === 1 }>
|
<button class="btn btn-outline" onclick={ pagePrecedente } disabled={ state.page === 1 }>
|
||||||
← Précédent
|
← Précédent
|
||||||
@@ -49,35 +56,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============== VUE DÉTAIL ============== -->
|
<!-- VUE DÉTAIL -->
|
||||||
<div if={ state.view === 'detail' && state.selected }>
|
<div if={ state.view === 'detail' && state.selected }>
|
||||||
<detail-view
|
<detail-view formation={ state.selected } onback={ retourRecherche }></detail-view>
|
||||||
formation={ state.selected }
|
|
||||||
onback={ retourRecherche }>
|
|
||||||
</detail-view>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div if={ state.view === 'detail' && !state.selected } class="message">
|
<div if={ state.view === 'detail' && !state.selected } class="message">
|
||||||
Chargement de la formation...
|
Chargement de la formation...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============== VUE COMPARATEUR ============== -->
|
<!-- VUE COMPARATEUR -->
|
||||||
<div if={ state.view === 'comparateur' }>
|
<div if={ state.view === 'comparateur' }>
|
||||||
|
|
||||||
<button class="btn btn-outline" onclick={ retourRecherche } style="margin-bottom: 16px;">← Retour à la recherche</button>
|
<button class="btn btn-outline" onclick={ retourRecherche } style="margin-bottom: 16px;">← Retour à la recherche</button>
|
||||||
|
|
||||||
<comparateur-view
|
<comparateur-view
|
||||||
formations={ state.selectedFormations }
|
formations={ state.selectedFormations }
|
||||||
onretirer={ retirerSelection }
|
onretirer={ retirerSelection }
|
||||||
onvider={ viderSelection }>
|
onvider={ viderSelection }>
|
||||||
</comparateur-view>
|
</comparateur-view>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
state: {
|
state: {
|
||||||
view: 'search',
|
view: 'search',
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -97,21 +99,17 @@
|
|||||||
var saved = localStorage.getItem('selectionFormations');
|
var saved = localStorage.getItem('selectionFormations');
|
||||||
var soi = this;
|
var soi = this;
|
||||||
|
|
||||||
// Charger la sélection locale si elle existe
|
// Restaurer la sélection depuis le localStorage
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try { this.state.selectedFormations = JSON.parse(saved); }
|
||||||
this.state.selectedFormations = JSON.parse(saved);
|
catch (e) { console.error('Erreur localStorage :', e); }
|
||||||
} catch (e) {
|
|
||||||
console.error('Erreur lecture localStorage :', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Écouter les changements de connexion Firebase
|
// Écouter les changements de session Firebase
|
||||||
window.firebaseServices.onUserChanged(function(user) {
|
window.firebaseServices.onUserChanged(function(user) {
|
||||||
|
|
||||||
soi.update({ user: 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) {
|
if (user) {
|
||||||
window.firebaseServices.loadUserData(user.uid)
|
window.firebaseServices.loadUserData(user.uid)
|
||||||
.then(function(donnees) {
|
.then(function(donnees) {
|
||||||
@@ -120,47 +118,34 @@
|
|||||||
localStorage.setItem('selectionFormations', JSON.stringify(donnees.selection));
|
localStorage.setItem('selectionFormations', JSON.stringify(donnees.selection));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) { console.error('Erreur Firestore :', err); });
|
||||||
console.error('Erreur chargement Firestore :', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('hashchange', function() {
|
|
||||||
soi.gererRoute();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Écouter les changements d'URL pour le routage
|
||||||
|
window.addEventListener('hashchange', function() { soi.gererRoute(); });
|
||||||
this.gererRoute();
|
this.gererRoute();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Appelée après une connexion ou inscription réussie
|
|
||||||
surConnexion() {
|
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() {
|
surDeconnexion() {
|
||||||
// On vide la sélection : l'utilisateur doit se reconnecter pour retrouver ses formations
|
|
||||||
this.update({ selectedFormations: [] });
|
this.update({ selectedFormations: [] });
|
||||||
localStorage.removeItem('selectionFormations');
|
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) {
|
async sauvegarderSelection(selection) {
|
||||||
|
|
||||||
localStorage.setItem('selectionFormations', JSON.stringify(selection));
|
localStorage.setItem('selectionFormations', JSON.stringify(selection));
|
||||||
|
|
||||||
if (this.state.user) {
|
if (this.state.user) {
|
||||||
try {
|
try { await window.firebaseServices.saveUserData(this.state.user.uid, { selection }); }
|
||||||
await window.firebaseServices.saveUserData(this.state.user.uid, { selection: selection });
|
catch (err) { console.error('Erreur sauvegarde :', err); }
|
||||||
} catch (err) {
|
|
||||||
console.error('Erreur sauvegarde Firestore :', err);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Lit l'ancre URL et affiche la vue correspondante
|
||||||
gererRoute() {
|
gererRoute() {
|
||||||
var ancre = window.location.hash || '#/';
|
var ancre = window.location.hash || '#/';
|
||||||
var chemin = ancre.slice(1) || '/';
|
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) {
|
async chargerFormationParId(id) {
|
||||||
var i;
|
var i;
|
||||||
|
|
||||||
for (i = 0; i < this.state.results.length; i++) {
|
for (i = 0; i < this.state.results.length; i++) {
|
||||||
if (this.state.results[i].id === id) {
|
if (this.state.results[i].id === id) { this.update({ selected: this.state.results[i] }); return; }
|
||||||
this.update({ selected: this.state.results[i] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i = 0; i < this.state.selectedFormations.length; i++) {
|
for (i = 0; i < this.state.selectedFormations.length; i++) {
|
||||||
if (this.state.selectedFormations[i].id === id) {
|
if (this.state.selectedFormations[i].id === id) { this.update({ selected: this.state.selectedFormations[i] }); return; }
|
||||||
this.update({ selected: this.state.selectedFormations[i] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En dernier recours : appel API avec un mot-clé extrait de l'ID
|
||||||
try {
|
try {
|
||||||
var parties = id.split('-');
|
var motCle = id.split('-').slice(1).join(' ').substring(0, 30);
|
||||||
var motCle = parties.slice(1).join(' ').substring(0, 30);
|
|
||||||
|
|
||||||
if (motCle) {
|
if (motCle) {
|
||||||
var donnees = await window.chargerFormations(motCle, 10, 0);
|
var donnees = await window.chargerFormations(motCle, 10, 0);
|
||||||
|
|
||||||
if (donnees.results && donnees.results.length > 0) {
|
if (donnees.results && donnees.results.length > 0) {
|
||||||
for (i = 0; i < donnees.results.length; i++) {
|
for (i = 0; i < donnees.results.length; i++) {
|
||||||
var formation = window.creerFormation(donnees.results[i]);
|
var f = window.creerFormation(donnees.results[i]);
|
||||||
if (formation.id === id) {
|
if (f.id === id) { this.update({ selected: f }); return; }
|
||||||
this.update({ selected: formation });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.update({ selected: window.creerFormation(donnees.results[0]) });
|
this.update({ selected: window.creerFormation(donnees.results[0]) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (erreur) {
|
} catch (erreur) { console.error('Erreur chargement formation :', erreur); }
|
||||||
console.error('Erreur chargement formation :', erreur);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
afficherDetail(index) {
|
afficherDetail(index) {
|
||||||
@@ -227,61 +200,29 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
async lancerRecherche(requete, filtres) {
|
async lancerRecherche(requete, filtres) {
|
||||||
this.update({
|
this.update({ loading: true, hasSearched: true, selected: null, view: 'search', query: requete, filters: filtres || {}, page: 1 });
|
||||||
loading: true,
|
|
||||||
hasSearched: true,
|
|
||||||
selected: null,
|
|
||||||
view: 'search',
|
|
||||||
query: requete,
|
|
||||||
filters: filtres || {},
|
|
||||||
page: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
window.location.hash = '#/';
|
window.location.hash = '#/';
|
||||||
await this.chargerPage(1);
|
await this.chargerPage(1);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Charge la page demandée en calculant l'offset (décalage) correspondant
|
||||||
async chargerPage(page) {
|
async chargerPage(page) {
|
||||||
this.update({ loading: true });
|
this.update({ loading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var decalage = (page - 1) * this.state.limit;
|
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 = [];
|
var formations = [];
|
||||||
|
|
||||||
if (donnees.results) {
|
if (donnees.results) {
|
||||||
for (var i = 0; i < donnees.results.length; i++) {
|
for (var i = 0; i < donnees.results.length; i++) {
|
||||||
var brut = donnees.results[i];
|
formations.push(window.creerFormation(donnees.results[i]));
|
||||||
formations.push(window.creerFormation(brut));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = 0;
|
this.update({ results: formations, total: donnees.total_count || 0, page, loading: false });
|
||||||
|
|
||||||
if (donnees.total_count) {
|
|
||||||
total = donnees.total_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.update({
|
|
||||||
results: formations,
|
|
||||||
total: total,
|
|
||||||
page: page,
|
|
||||||
loading: false
|
|
||||||
});
|
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
console.error(erreur);
|
console.error(erreur);
|
||||||
this.update({
|
this.update({ results: [], total: 0, loading: false });
|
||||||
results: [],
|
|
||||||
total: 0,
|
|
||||||
loading: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -290,48 +231,32 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
async pageSuivante() {
|
async pageSuivante() {
|
||||||
if (this.state.page < this.nombreTotalPages()) {
|
if (this.state.page < this.nombreTotalPages()) { await this.chargerPage(this.state.page + 1); }
|
||||||
await this.chargerPage(this.state.page + 1);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async pagePrecedente() {
|
async pagePrecedente() {
|
||||||
if (this.state.page > 1) {
|
if (this.state.page > 1) { await this.chargerPage(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) {
|
ajouterSelection(index) {
|
||||||
var formation = this.state.results[index];
|
var formation = this.state.results[index];
|
||||||
var selection = this.state.selectedFormations.slice();
|
var selection = this.state.selectedFormations.slice();
|
||||||
var dejaAjout = false;
|
|
||||||
|
|
||||||
for (var i = 0; i < selection.length; i++) {
|
for (var i = 0; i < selection.length; i++) {
|
||||||
if (selection[i].id === formation.id) {
|
if (selection[i].id === formation.id) { return; } // doublon
|
||||||
dejaAjout = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dejaAjout) {
|
|
||||||
selection.push(formation);
|
selection.push(formation);
|
||||||
this.update({ selectedFormations: selection });
|
this.update({ selectedFormations: selection });
|
||||||
this.sauvegarderSelection(selection);
|
this.sauvegarderSelection(selection);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Retire la formation avec cet id de la sélection
|
||||||
retirerSelection(id) {
|
retirerSelection(id) {
|
||||||
var nouvelleSelection = [];
|
var nouvelle = this.state.selectedFormations.filter(function(f) { return f.id !== id; });
|
||||||
|
this.update({ selectedFormations: nouvelle });
|
||||||
for (var i = 0; i < this.state.selectedFormations.length; i++) {
|
this.sauvegarderSelection(nouvelle);
|
||||||
var f = this.state.selectedFormations[i];
|
|
||||||
|
|
||||||
if (f.id !== id) {
|
|
||||||
nouvelleSelection.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.update({ selectedFormations: nouvelleSelection });
|
|
||||||
this.sauvegarderSelection(nouvelleSelection);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
viderSelection() {
|
viderSelection() {
|
||||||
|
|||||||
@@ -1,27 +1,50 @@
|
|||||||
<auth-panel>
|
<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 }>
|
<div class="auth-header-btn" if={ !props.user }>
|
||||||
<button class="btn btn-auth" onclick={ ouvrirModale }>
|
<button class="btn btn-auth" onclick={ ouvrirModale }>
|
||||||
<span class="auth-icon"></span> Connexion
|
<span class="auth-icon"></span> Connexion
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 }>
|
<div class="auth-user-info" if={ props.user }>
|
||||||
<span class="auth-email">{ props.user.email }</span>
|
<span class="auth-email">{ props.user.email }</span>
|
||||||
<button class="btn btn-auth-logout" onclick={ seDeconnecter }>Déconnexion</button>
|
<button class="btn btn-auth-logout" onclick={ seDeconnecter }>Déconnexion</button>
|
||||||
</div>
|
</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-overlay" if={ state.visible } onclick={ cliquerFond }>
|
||||||
<div class="auth-modal">
|
<div class="auth-modal">
|
||||||
|
|
||||||
|
<!-- Croix de fermeture -->
|
||||||
<button class="auth-modal-close" onclick={ fermerModale }>✕</button>
|
<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>
|
<h2 class="auth-modal-title">{ state.titre }</h2>
|
||||||
|
|
||||||
<!-- Onglets Connexion / Inscription -->
|
<!-- Onglets pour basculer entre connexion et inscription -->
|
||||||
<div class="auth-tabs">
|
<div class="auth-tabs">
|
||||||
<button class={ state.classBtnConnexion } onclick={ afficherConnexion }>
|
<button class={ state.classBtnConnexion } onclick={ afficherConnexion }>
|
||||||
Connexion
|
Connexion
|
||||||
@@ -31,7 +54,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<form onsubmit={ validerFormulaire } class="auth-form">
|
||||||
|
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
@@ -55,11 +79,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message d'erreur -->
|
<!-- Message d'erreur : affiché uniquement si state.erreur est défini -->
|
||||||
<div class="auth-error" if={ state.erreur }>
|
<div class="auth-error" if={ state.erreur }>
|
||||||
{ state.erreur }
|
{ state.erreur }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton désactivé pendant le chargement pour éviter les doubles soumissions -->
|
||||||
<button type="submit" class="btn btn-primary auth-submit" disabled={ state.chargement }>
|
<button type="submit" class="btn btn-primary auth-submit" disabled={ state.chargement }>
|
||||||
{ state.labelBouton }
|
{ state.labelBouton }
|
||||||
</button>
|
</button>
|
||||||
@@ -71,6 +96,14 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
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: {
|
state: {
|
||||||
visible: false,
|
visible: false,
|
||||||
mode: 'connexion',
|
mode: 'connexion',
|
||||||
@@ -82,21 +115,26 @@
|
|||||||
classBtnInscription: 'auth-tab'
|
classBtnInscription: 'auth-tab'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Ouvrir la modale et effacer les erreurs précédentes
|
||||||
ouvrirModale() {
|
ouvrirModale() {
|
||||||
this.update({ visible: true, erreur: null });
|
this.update({ visible: true, erreur: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Fermer la modale et effacer les erreurs
|
||||||
fermerModale() {
|
fermerModale() {
|
||||||
this.update({ visible: false, erreur: null });
|
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) {
|
cliquerFond(e) {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
this.fermerModale();
|
this.fermerModale();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Basculer vers l'onglet "Connexion"
|
||||||
afficherConnexion() {
|
afficherConnexion() {
|
||||||
this.update({
|
this.update({
|
||||||
mode: 'connexion',
|
mode: 'connexion',
|
||||||
@@ -108,6 +146,7 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Basculer vers l'onglet "Inscription"
|
||||||
afficherInscription() {
|
afficherInscription() {
|
||||||
this.update({
|
this.update({
|
||||||
mode: 'inscription',
|
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) {
|
async validerFormulaire(e) {
|
||||||
e.preventDefault();
|
e.preventDefault(); // empêche la soumission HTML classique (rechargement de page)
|
||||||
|
|
||||||
var email = e.target.email.value.trim();
|
var email = e.target.email.value.trim();
|
||||||
var password = e.target.password.value;
|
var password = e.target.password.value;
|
||||||
var services = window.firebaseServices;
|
var services = window.firebaseServices;
|
||||||
|
|
||||||
this.update({ chargement: true, erreur: null });
|
this.update({ chargement: true, erreur: null }); // désactive le bouton pendant l'appel
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (this.state.mode === 'inscription') {
|
if (this.state.mode === 'inscription') {
|
||||||
await services.createAccount(email, password);
|
await services.createAccount(email, password); // crée le compte Firebase
|
||||||
} else {
|
} else {
|
||||||
await services.login(email, password);
|
await services.login(email, password); // connecte l'utilisateur
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update({ visible: false, chargement: false });
|
this.update({ visible: false, chargement: false });
|
||||||
|
// Notifie le parent que l'authentification a réussi
|
||||||
this.props.onauth && this.props.onauth();
|
this.props.onauth && this.props.onauth();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
||||||
|
// Traduction des codes d'erreur Firebase en messages utilisateur compréhensibles
|
||||||
var messageErreur = 'Une erreur est survenue.';
|
var messageErreur = 'Une erreur est survenue.';
|
||||||
|
|
||||||
if (err.code === 'auth/email-already-in-use') {
|
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() {
|
async seDeconnecter() {
|
||||||
try {
|
try {
|
||||||
await window.firebaseServices.logout();
|
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();
|
this.props.onlogout && this.props.onlogout();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur déconnexion :', err);
|
console.error('Erreur déconnexion :', err);
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
<comparateur-view>
|
<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 }>
|
<div class="detail-card comparateur-card" if={ props.formations.length > 0 }>
|
||||||
<h2>Comparateur de formations</h2>
|
<h2>Comparateur de formations</h2>
|
||||||
|
|
||||||
<p>Choisis ton profil pour estimer tes chances d'intégration.</p>
|
<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 class="compare-controls">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Note moyenne de l'étudiant (utilisée dans le calcul du score) -->
|
||||||
<label><b>Note moyenne :</b></label><br />
|
<label><b>Note moyenne :</b></label><br />
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -20,6 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Série bac : détermine quel % de bacheliers de ce type ont été acceptés -->
|
||||||
<label><b>Série :</b></label><br />
|
<label><b>Série :</b></label><br />
|
||||||
<select onchange={ mettreAJourSerie }>
|
<select onchange={ mettreAJourSerie }>
|
||||||
<option value="general" selected={ state.serie === 'general' }>Général</option>
|
<option value="general" selected={ state.serie === 'general' }>Général</option>
|
||||||
@@ -29,6 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Critère de tri des cartes de formations -->
|
||||||
<label><b>Trier par :</b></label><br />
|
<label><b>Trier par :</b></label><br />
|
||||||
<select onchange={ mettreAJourTri }>
|
<select onchange={ mettreAJourTri }>
|
||||||
<option value="nom" selected={ state.sortBy === 'nom' }>Nom</option>
|
<option value="nom" selected={ state.sortBy === 'nom' }>Nom</option>
|
||||||
@@ -43,6 +67,8 @@
|
|||||||
|
|
||||||
<hr />
|
<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) }>
|
<div each={ f in obtenirSelectionTriee() } key={ f.id } class={ classeCarte(f) }>
|
||||||
<h4>{ f.nom }</h4>
|
<h4>{ f.nom }</h4>
|
||||||
|
|
||||||
@@ -52,6 +78,7 @@
|
|||||||
<p><b>Capacité :</b> { f.capacite }</p>
|
<p><b>Capacité :</b> { f.capacite }</p>
|
||||||
<p><b>Taux d'accès :</b> { f.tauxAcces }%</p>
|
<p><b>Taux d'accès :</b> { f.tauxAcces }%</p>
|
||||||
|
|
||||||
|
<!-- Répartition des intégrés par type de bac -->
|
||||||
<p>
|
<p>
|
||||||
<b>Intégrés :</b>
|
<b>Intégrés :</b>
|
||||||
Général { f.pctGeneral }% /
|
Général { f.pctGeneral }% /
|
||||||
@@ -59,20 +86,22 @@
|
|||||||
Pro { f.pctPro }%
|
Pro { f.pctPro }%
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Résultat de l'estimation avec badge coloré + détail des critères -->
|
||||||
<p class="estimation-result">
|
<p class="estimation-result">
|
||||||
<span class={ classeEstimation(f) }>
|
<span class={ classeEstimation(f) }>
|
||||||
{ estimerFormation(f) }
|
{ estimerFormation(f) } <!-- ex: "Favorable", "Difficile"... -->
|
||||||
</span>
|
</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>
|
</p>
|
||||||
|
|
||||||
|
<!-- Bouton pour retirer cette formation de la sélection -->
|
||||||
<button class="btn btn-small btn-outline" onclick={ retirerSelection.bind(this, f.id) }>
|
<button class="btn btn-small btn-outline" onclick={ retirerSelection.bind(this, f.id) }>
|
||||||
Retirer
|
Retirer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 }>
|
<div class="message" if={ props.formations.length === 0 }>
|
||||||
<h3>Aucune formation sélectionnée</h3>
|
<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>
|
<p>Retourne à la <a href="#/">recherche</a> et clique sur "Ajouter à la sélection" pour comparer des formations.</p>
|
||||||
@@ -81,58 +110,64 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
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: {
|
state: {
|
||||||
note: 12,
|
note: 12,
|
||||||
serie: 'general',
|
serie: 'general',
|
||||||
sortBy: 'nom'
|
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) {
|
// --- Actions sur la sélection (déléguées au parent via props) ---
|
||||||
this.update({ note: Number(e.target.value) });
|
// 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(); },
|
||||||
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 -----
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 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() {
|
obtenirSelectionTriee() {
|
||||||
var selection = (this.props.formations || []).slice();
|
var selection = (this.props.formations || []).slice(); // copie du tableau
|
||||||
var soi = this;
|
var soi = this;
|
||||||
|
|
||||||
if (this.state.sortBy === 'taux') {
|
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; });
|
selection.sort(function(a, b) { return b.tauxAcces - a.tauxAcces; });
|
||||||
} else if (this.state.sortBy === 'ville') {
|
} 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); });
|
selection.sort(function(a, b) { return a.ville.localeCompare(b.ville); });
|
||||||
} else if (this.state.sortBy === 'estimation') {
|
} 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); });
|
selection.sort(function(a, b) { return soi.calculerScore(b) - soi.calculerScore(a); });
|
||||||
} else {
|
} else {
|
||||||
|
// Tri alphabétique par nom de formation (ordre par défaut)
|
||||||
selection.sort(function(a, b) { return a.nom.localeCompare(b.nom); });
|
selection.sort(function(a, b) { return a.nom.localeCompare(b.nom); });
|
||||||
}
|
}
|
||||||
|
|
||||||
return selection;
|
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) {
|
pourcentageSerie(f) {
|
||||||
if (this.state.serie === 'general') { return f.pctGeneral || 0; }
|
if (this.state.serie === 'general') { return f.pctGeneral || 0; }
|
||||||
if (this.state.serie === 'techno') { return f.pctTechno || 0; }
|
if (this.state.serie === 'techno') { return f.pctTechno || 0; }
|
||||||
@@ -140,37 +175,61 @@
|
|||||||
return 0;
|
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) {
|
calculerScore(f) {
|
||||||
var score = 0;
|
var score = 0;
|
||||||
var note = this.state.note;
|
var note = this.state.note;
|
||||||
var tauxAcces = f.tauxAcces || 0;
|
var tauxAcces = f.tauxAcces || 0;
|
||||||
var pctSerie = this.pourcentageSerie(f);
|
var pctSerie = this.pourcentageSerie(f);
|
||||||
|
|
||||||
// Critère 1 : taux d'accès de la formation
|
// Critère 1 : taux d'accès de la formation (facilité globale d'entrée)
|
||||||
if (tauxAcces >= 80) { score += 30; }
|
if (tauxAcces >= 80) { score += 30; } // très accessible
|
||||||
else if (tauxAcces >= 50) { score += 24; }
|
else if (tauxAcces >= 50) { score += 24; }
|
||||||
else if (tauxAcces >= 30) { score += 16; }
|
else if (tauxAcces >= 30) { score += 16; }
|
||||||
else if (tauxAcces >= 15) { score += 8; }
|
else if (tauxAcces >= 15) { score += 8; }
|
||||||
else { score += 2; }
|
else { score += 2; } // très sélectif
|
||||||
|
|
||||||
// Critère 2 : note de l'étudiant
|
// 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 >= 15) { score += 32; }
|
||||||
else if (note >= 13) { score += 22; }
|
else if (note >= 13) { score += 22; }
|
||||||
else if (note >= 11) { score += 14; }
|
else if (note >= 11) { score += 14; }
|
||||||
else if (note >= 9) { score += 6; }
|
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
|
// Critère 3 : proportion de bacheliers de la même série acceptés dans cette formation
|
||||||
if (pctSerie >= 60) { score += 30; }
|
if (pctSerie >= 60) { score += 30; } // la série est très représentée
|
||||||
else if (pctSerie >= 40) { score += 24; }
|
else if (pctSerie >= 40) { score += 24; }
|
||||||
else if (pctSerie >= 20) { score += 16; }
|
else if (pctSerie >= 20) { score += 16; }
|
||||||
else if (pctSerie >= 5) { score += 8; }
|
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) {
|
estimerFormation(f) {
|
||||||
var score = this.calculerScore(f);
|
var score = this.calculerScore(f);
|
||||||
if (score >= 85) { return 'Très favorable'; }
|
if (score >= 85) { return 'Très favorable'; }
|
||||||
@@ -180,8 +239,10 @@
|
|||||||
return 'Très difficile';
|
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) {
|
classeEstimation(f) {
|
||||||
var r = this.estimerFormation(f);
|
var r = this.estimerFormation(f);
|
||||||
if (r === 'Très favorable') { return 'estimate tres-favorable'; }
|
if (r === 'Très favorable') { return 'estimate tres-favorable'; }
|
||||||
@@ -191,6 +252,7 @@
|
|||||||
return 'estimate tres-difficile';
|
return 'estimate tres-difficile';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Classe pour la carte entière (fond coloré)
|
||||||
classeCarte(f) {
|
classeCarte(f) {
|
||||||
var r = this.estimerFormation(f);
|
var r = this.estimerFormation(f);
|
||||||
if (r === 'Très favorable') { return 'card card-tres-favorable'; }
|
if (r === 'Très favorable') { return 'card card-tres-favorable'; }
|
||||||
@@ -200,11 +262,18 @@
|
|||||||
return 'card card-tres-difficile';
|
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) {
|
detailEstimation(f) {
|
||||||
var tauxAcces = f.tauxAcces || 0;
|
var tauxAcces = f.tauxAcces || 0;
|
||||||
var pctSerie = this.pourcentageSerie(f);
|
var pctSerie = this.pourcentageSerie(f);
|
||||||
var nomSerie = '';
|
var nomSerie = '';
|
||||||
|
|
||||||
|
// Libellé court de la série pour l'affichage
|
||||||
if (this.state.serie === 'general') { nomSerie = 'Gén'; }
|
if (this.state.serie === 'general') { nomSerie = 'Gén'; }
|
||||||
else if (this.state.serie === 'techno') { nomSerie = 'Techno'; }
|
else if (this.state.serie === 'techno') { nomSerie = 'Techno'; }
|
||||||
else { nomSerie = 'Pro'; }
|
else { nomSerie = 'Pro'; }
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
<detail-view>
|
<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 2020–2025 (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">
|
<div if={ props.formation } class="detail-page">
|
||||||
<h2>Formation</h2>
|
<h2>Formation</h2>
|
||||||
<h1 class="formation-title">{ props.formation.etablissement } - { props.formation.nom }</h1>
|
<h1 class="formation-title">{ props.formation.etablissement } - { props.formation.nom }</h1>
|
||||||
@@ -197,57 +218,87 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// ÉTAT LOCAL du composant
|
||||||
|
// historique : tableau des données historiques 2020-2025
|
||||||
|
// chargementHistorique : true pendant la requête API historique
|
||||||
|
// ========================================================================
|
||||||
state: {
|
state: {
|
||||||
historique: [],
|
historique: [],
|
||||||
chargementHistorique: false
|
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() {
|
onMounted() {
|
||||||
this.afficherGraphiques();
|
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() {
|
onUpdated() {
|
||||||
this.afficherGraphiques();
|
this.afficherGraphiques();
|
||||||
|
|
||||||
|
// On attend d'avoir les données historiques avant de construire ces graphiques
|
||||||
if (this.state.historique.length > 0) {
|
if (this.state.historique.length > 0) {
|
||||||
this.afficherGraphiquesHistoriques();
|
this.afficherGraphiquesHistoriques();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Retour à la liste des résultats
|
// Retour à la liste des résultats (délègue au parent via props.onback)
|
||||||
retourListe() {
|
retourListe() {
|
||||||
this.props.onback();
|
this.props.onback();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Limiter une valeur entre 0 et 1 pour Charts.css
|
// ========================================================================
|
||||||
|
// limiterValeur(val)
|
||||||
|
// RÔLE : convertir un pourcentage (0–100) en proportion (0–1) 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) {
|
limiterValeur(val) {
|
||||||
if (val === null || val === undefined || isNaN(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) {
|
if (v > 1) { return 1; } // max : barre pleine
|
||||||
return 1;
|
if (v < 0) { return 0; } // min : barre vide
|
||||||
}
|
|
||||||
if (v < 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
afficherGraphiques() {
|
||||||
var f = this.props.formation;
|
var f = this.props.formation;
|
||||||
if (!f) {
|
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 graphBac = this.$('[ref="graphBac"]');
|
||||||
var graphMentions = this.$('[ref="graphMentions"]');
|
var graphMentions = this.$('[ref="graphMentions"]');
|
||||||
var graphProfil = this.$('[ref="graphProfil"]');
|
var graphProfil = this.$('[ref="graphProfil"]');
|
||||||
|
|
||||||
|
// Graphique 1 : répartition par type de bac (colonnes verticales)
|
||||||
if (graphBac) {
|
if (graphBac) {
|
||||||
graphBac.innerHTML = this.construireGraphiqueColonnes([
|
graphBac.innerHTML = this.construireGraphiqueColonnes([
|
||||||
{ label: 'Général', valeur: f.pctGeneral, couleur: '#3d7fff' },
|
{ 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) {
|
if (graphMentions) {
|
||||||
graphMentions.innerHTML = this.construireGraphiqueColonnes([
|
graphMentions.innerHTML = this.construireGraphiqueColonnes([
|
||||||
{ label: 'Sans', valeur: f.pctSansMention, couleur: '#94a3b8' },
|
{ label: 'Sans', valeur: f.pctSansMention, couleur: '#94a3b8' },
|
||||||
@@ -266,6 +318,7 @@
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graphique 3 : profil sociologique (barres horizontales)
|
||||||
if (graphProfil) {
|
if (graphProfil) {
|
||||||
graphProfil.innerHTML = this.construireGraphiqueBarres([
|
graphProfil.innerHTML = this.construireGraphiqueBarres([
|
||||||
{ label: 'Femmes', valeur: f.pctFemmes, couleur: '#a78bfa' },
|
{ label: 'Femmes', valeur: f.pctFemmes, couleur: '#a78bfa' },
|
||||||
@@ -275,23 +328,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// chargerHistorique()
|
||||||
|
// RÔLE : charger les données 2020–2025 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() {
|
async chargerHistorique() {
|
||||||
var f = this.props.formation;
|
var f = this.props.formation;
|
||||||
if (!f) {
|
if (!f) {
|
||||||
return;
|
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];
|
var codUai = f.id.split('-')[0];
|
||||||
|
|
||||||
if (!codUai || !window.chargerHistoriqueFormation) {
|
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 {
|
try {
|
||||||
var historique = await window.chargerHistoriqueFormation(codUai, f.nom);
|
var historique = await window.chargerHistoriqueFormation(codUai, f.nom);
|
||||||
this.update({ historique: historique, chargementHistorique: false });
|
this.update({ historique: historique, chargementHistorique: false });
|
||||||
|
// onUpdated() est automatiquement appelé après update() → affichera les graphiques
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Erreur chargement historique :', e);
|
console.error('Erreur chargement historique :', e);
|
||||||
this.update({ historique: [], chargementHistorique: false });
|
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 (0–100) → 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) {
|
construireGraphiqueColonnes(elements) {
|
||||||
var lignes = '';
|
var lignes = '';
|
||||||
|
|
||||||
for (var i = 0; i < elements.length; i++) {
|
for (var i = 0; i < elements.length; i++) {
|
||||||
var el = elements[i];
|
var el = elements[i];
|
||||||
var taille = this.limiterValeur(el.valeur);
|
var taille = this.limiterValeur(el.valeur); // proportion 0–1 pour --size
|
||||||
var affiche = el.valeur || 0;
|
var affiche = el.valeur || 0; // valeur brute à afficher dans le tooltip
|
||||||
|
|
||||||
lignes += '<tr>';
|
lignes += '<tr>';
|
||||||
lignes += '<th scope="row">' + el.label + '</th>';
|
lignes += '<th scope="row">' + el.label + '</th>';
|
||||||
lignes += '<td style="--size: ' + taille + '; --color: ' + el.couleur + ';">';
|
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>';
|
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">'
|
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>'
|
+ '<thead><tr><th scope="col">Type</th><th scope="col">%</th></tr></thead>'
|
||||||
+ '<tbody>' + lignes + '</tbody></table>';
|
+ '<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) {
|
construireGraphiqueBarres(elements) {
|
||||||
var lignes = '';
|
var lignes = '';
|
||||||
|
|
||||||
for (var i = 0; i < elements.length; i++) {
|
for (var i = 0; i < elements.length; i++) {
|
||||||
var el = elements[i];
|
var el = elements[i];
|
||||||
var taille = this.limiterValeur(el.valeur);
|
var taille = this.limiterValeur(el.valeur); // proportion 0–1
|
||||||
var affiche = el.valeur || 0;
|
var affiche = el.valeur || 0;
|
||||||
|
|
||||||
lignes += '<tr>';
|
lignes += '<tr>';
|
||||||
@@ -417,6 +514,7 @@
|
|||||||
lignes += '</td></tr>';
|
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">'
|
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>'
|
+ '<thead><tr><th scope="col">Catégorie</th><th scope="col">%</th></tr></thead>'
|
||||||
+ '<tbody>' + lignes + '</tbody></table>';
|
+ '<tbody>' + lignes + '</tbody></table>';
|
||||||
|
|||||||
@@ -1,113 +1,191 @@
|
|||||||
<map-view>
|
<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">
|
<div class="map-box">
|
||||||
<h3>Carte des formations</h3>
|
<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 class="map" ref="carte"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
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() {
|
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);
|
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);
|
this.groupeMarqueurs = L.layerGroup().addTo(this.carte);
|
||||||
|
|
||||||
|
// Index id → marqueur : permet à centrerSurFormation() de retrouver rapidement un marqueur
|
||||||
this.marqueursIndex = {};
|
this.marqueursIndex = {};
|
||||||
|
|
||||||
|
// Ajout du fond de carte OpenStreetMap (tuiles PNG)
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(this.carte);
|
}).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() {
|
setTimeout(function() {
|
||||||
if (composant.carte) {
|
if (composant.carte) { composant.carte.invalidateSize(); }
|
||||||
composant.carte.invalidateSize();
|
|
||||||
}
|
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (composant.carte) {
|
if (composant.carte) { composant.carte.invalidateSize(); }
|
||||||
composant.carte.invalidateSize();
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
// Exposition de mapFocus sur window pour permettre à result-list
|
||||||
|
// de centrer la carte sur une formation en cliquant "Localiser"
|
||||||
window.mapFocus = function(id) {
|
window.mapFocus = function(id) {
|
||||||
composant.centrerSurFormation(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() {
|
onUpdated() {
|
||||||
this.afficherMarqueurs();
|
this.afficherMarqueurs();
|
||||||
|
|
||||||
var composant = this;
|
var composant = this;
|
||||||
|
|
||||||
if (this.carte) {
|
if (this.carte) {
|
||||||
setTimeout(function() {
|
setTimeout(function() { composant.carte.invalidateSize(); }, 100);
|
||||||
composant.carte.invalidateSize();
|
setTimeout(function() { composant.carte.invalidateSize(); }, 300);
|
||||||
}, 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() {
|
onBeforeUnmount() {
|
||||||
if (this.carte) {
|
if (this.carte) {
|
||||||
this.carte.remove();
|
this.carte.remove(); // détruit la carte et libère la mémoire
|
||||||
this.carte = null;
|
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() {
|
afficherMarqueurs() {
|
||||||
if (!this.carte || !this.groupeMarqueurs) {
|
if (!this.carte || !this.groupeMarqueurs) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.groupeMarqueurs.clearLayers();
|
this.groupeMarqueurs.clearLayers(); // supprime tous les marqueurs existants
|
||||||
this.marqueursIndex = {};
|
this.marqueursIndex = {}; // réinitialise l'index
|
||||||
|
|
||||||
var coordonnees = [];
|
var coordonnees = []; // liste des coordonnées pour fitBounds
|
||||||
var formations = this.props.results || [];
|
var formations = this.props.results || [];
|
||||||
|
|
||||||
for (var i = 0; i < formations.length; i++) {
|
for (var i = 0; i < formations.length; i++) {
|
||||||
var f = formations[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) {
|
if (f.latitude != null && f.longitude != null) {
|
||||||
var marqueur = L.marker([f.latitude, f.longitude]);
|
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.bindPopup('<b>' + f.nom + '</b><br>' + f.ville);
|
||||||
marqueur.addTo(this.groupeMarqueurs);
|
marqueur.addTo(this.groupeMarqueurs);
|
||||||
|
|
||||||
|
// On indexe le marqueur par l'ID de la formation pour centrerSurFormation()
|
||||||
this.marqueursIndex[f.id] = marqueur;
|
this.marqueursIndex[f.id] = marqueur;
|
||||||
coordonnees.push([f.latitude, f.longitude]);
|
coordonnees.push([f.latitude, f.longitude]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ajuste le zoom pour montrer tous les marqueurs (avec 20px de marge)
|
||||||
if (coordonnees.length > 0) {
|
if (coordonnees.length > 0) {
|
||||||
this.carte.fitBounds(coordonnees, { padding: [20, 20] });
|
this.carte.fitBounds(coordonnees, { padding: [20, 20] });
|
||||||
} else {
|
} else {
|
||||||
|
// Aucun marqueur → on recentre sur la France
|
||||||
this.carte.setView([46.8, 2.5], 6);
|
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) {
|
centrerSurFormation(id) {
|
||||||
var marqueur = this.marqueursIndex[id];
|
var marqueur = this.marqueursIndex[id];
|
||||||
|
|
||||||
if (marqueur && this.carte) {
|
if (marqueur && this.carte) {
|
||||||
var divCarte = this.$('div[ref="carte"]');
|
var divCarte = this.$('div[ref="carte"]');
|
||||||
|
|
||||||
|
// Scroll vers la carte pour que l'utilisateur la voie avant le zoom
|
||||||
if (divCarte) {
|
if (divCarte) {
|
||||||
divCarte.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
divCarte.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
|
|
||||||
var composant = this;
|
var composant = this;
|
||||||
|
|
||||||
|
// On attend la fin du scroll (~400ms) avant de zoomer sur le marqueur
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
composant.carte.invalidateSize();
|
composant.carte.invalidateSize();
|
||||||
|
// Zoom niveau 13 (ville) avec animation, puis ouverture du popup
|
||||||
composant.carte.setView(marqueur.getLatLng(), 13, { animate: true });
|
composant.carte.setView(marqueur.getLatLng(), 13, { animate: true });
|
||||||
marqueur.openPopup();
|
marqueur.openPopup();
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
<result-list>
|
<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">
|
<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 }>
|
<div class="message" if={ props.results.length === 0 && props.hasSearched && !props.loading }>
|
||||||
Aucun résultat trouvé
|
Aucun résultat trouvé
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Message de chargement : affiché pendant l'appel API -->
|
||||||
<div class="message" if={ props.loading }>
|
<div class="message" if={ props.loading }>
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</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">
|
<div each={ (formation, index) in props.results } key={ formation.id } class="card">
|
||||||
<h3>{ formation.nom }</h3>
|
<h3>{ formation.nom }</h3>
|
||||||
<p><b>Établissement :</b> { formation.etablissement }</p>
|
<p><b>Établissement :</b> { formation.etablissement }</p>
|
||||||
@@ -16,8 +38,11 @@
|
|||||||
<p><b>Filière :</b> { formation.filiere }</p>
|
<p><b>Filière :</b> { formation.filiere }</p>
|
||||||
<p><b>Taux d'accès :</b> { formation.tauxAcces }%</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={ afficherDetail.bind(this, index) }>Voir détail</button>
|
||||||
<button onclick={ ajouterALaSelection.bind(this, index) }>Ajouter à la sélection</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>
|
<button onclick={ localiserSurCarte.bind(this, formation) } if={ formation.latitude != null }>Localiser</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,20 +51,28 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
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) {
|
afficherDetail(index) {
|
||||||
this.props.ondetail(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) {
|
ajouterALaSelection(index) {
|
||||||
this.props.onselect(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) {
|
localiserSurCarte(formation) {
|
||||||
if (window.mapFocus) {
|
if (window.mapFocus) {
|
||||||
window.mapFocus(formation.id);
|
window.mapFocus(formation.id); // <map-view> centre la carte sur ce marqueur
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<search-bar>
|
<search-bar>
|
||||||
|
<!--
|
||||||
|
Barre de recherche + filtres avancés (toggle)
|
||||||
|
Communique avec le parent via props.onsearch(query, filters)
|
||||||
|
-->
|
||||||
|
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -10,6 +15,7 @@
|
|||||||
<button onclick={ submitSearch }>Rechercher</button>
|
<button onclick={ submitSearch }>Rechercher</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton pour afficher/masquer les filtres avancés -->
|
||||||
<div class="filters-toggle">
|
<div class="filters-toggle">
|
||||||
<button class="btn btn-small btn-outline" onclick={ toggleFilters }>
|
<button class="btn btn-small btn-outline" onclick={ toggleFilters }>
|
||||||
{ state.labelFiltres }
|
{ state.labelFiltres }
|
||||||
@@ -18,6 +24,8 @@
|
|||||||
|
|
||||||
<div class="filters-panel" if={ state.showFilters }>
|
<div class="filters-panel" if={ state.showFilters }>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
|
|
||||||
|
<!-- Les valeurs correspondent exactement aux champs de l'API Parcoursup -->
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label>Type de formation</label>
|
<label>Type de formation</label>
|
||||||
<select onchange={ updateFiliere }>
|
<select onchange={ updateFiliere }>
|
||||||
@@ -74,7 +82,6 @@
|
|||||||
<label>Taux d'accès min (%)</label>
|
<label>Taux d'accès min (%)</label>
|
||||||
<input type="number" min="0" max="100" value={ state.tauxMin } oninput={ updateTauxMin } placeholder="0" />
|
<input type="number" min="0" max="100" value={ state.tauxMin } oninput={ updateTauxMin } placeholder="0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label>Taux d'accès max (%)</label>
|
<label>Taux d'accès max (%)</label>
|
||||||
<input type="number" min="0" max="100" value={ state.tauxMax } oninput={ updateTauxMax } placeholder="100" />
|
<input type="number" min="0" max="100" value={ state.tauxMax } oninput={ updateTauxMax } placeholder="100" />
|
||||||
@@ -84,6 +91,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
state: {
|
state: {
|
||||||
query: '',
|
query: '',
|
||||||
showFilters: false,
|
showFilters: false,
|
||||||
@@ -95,42 +103,25 @@
|
|||||||
tauxMax: 100
|
tauxMax: 100
|
||||||
},
|
},
|
||||||
|
|
||||||
updateQuery(e) {
|
updateQuery(e) { this.update({ query: e.target.value }); },
|
||||||
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) {
|
handleKey(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') { this.submitSearch(); }
|
||||||
this.submitSearch();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Affiche ou masque les filtres avancés
|
||||||
toggleFilters() {
|
toggleFilters() {
|
||||||
var visible = !this.state.showFilters;
|
var visible = !this.state.showFilters;
|
||||||
var label = visible ? 'Masquer les filtres' : 'Filtres avancés';
|
this.update({ showFilters: visible, labelFiltres: 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) });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Collecte tous les filtres et appelle le callback du parent
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
var filters = {
|
var filters = {
|
||||||
filiere: this.state.filiere,
|
filiere: this.state.filiere,
|
||||||
@@ -139,7 +130,6 @@
|
|||||||
tauxMin: this.state.tauxMin,
|
tauxMin: this.state.tauxMin,
|
||||||
tauxMax: this.state.tauxMax
|
tauxMax: this.state.tauxMax
|
||||||
};
|
};
|
||||||
|
|
||||||
this.props.onsearch(this.state.query, filters);
|
this.props.onsearch(this.state.query, filters);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-25
@@ -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 { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +24,8 @@ import {
|
|||||||
} from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
|
} 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 = {
|
const firebaseConfig = {
|
||||||
apiKey: "AIzaSyDr1jMgGm0Oj_bOiWY-8Gy27IlzkmAzlOM",
|
apiKey: "AIzaSyDr1jMgGm0Oj_bOiWY-8Gy27IlzkmAzlOM",
|
||||||
authDomain: "parcoursupp-expl.firebaseapp.com",
|
authDomain: "parcoursupp-expl.firebaseapp.com",
|
||||||
@@ -25,56 +35,47 @@ const firebaseConfig = {
|
|||||||
appId: "1:973054617217:web:4d52af4280396976228f80"
|
appId: "1:973054617217:web:4d52af4280396976228f80"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialisation des services uniques pour toute l'app
|
||||||
const app = initializeApp(firebaseConfig);
|
const app = initializeApp(firebaseConfig);
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app); // Service dédié à l'authentification
|
||||||
const db = getFirestore(app);
|
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) {
|
async function createAccount(email, password) {
|
||||||
return createUserWithEmailAndPassword(auth, email, password);
|
return createUserWithEmailAndPassword(auth, email, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se connecter avec email et mot de passe
|
|
||||||
async function login(email, password) {
|
async function login(email, password) {
|
||||||
return signInWithEmailAndPassword(auth, email, password);
|
return signInWithEmailAndPassword(auth, email, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se déconnecter
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
return signOut(auth);
|
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) {
|
function onUserChanged(callback) {
|
||||||
return onAuthStateChanged(auth, 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) {
|
async function saveUserData(uid, data) {
|
||||||
await setDoc(doc(db, "users", uid), data, { merge: true });
|
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) {
|
async function loadUserData(uid) {
|
||||||
var snap = await getDoc(doc(db, "users", uid));
|
var snap = await getDoc(doc(db, "users", uid));
|
||||||
|
return snap.exists() ? snap.data() : null;
|
||||||
if (snap.exists()) {
|
|
||||||
return snap.data();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export {
|
export { auth, db, createAccount, login, logout, onUserChanged, saveUserData, loadUserData };
|
||||||
auth,
|
|
||||||
db,
|
|
||||||
createAccount,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
onUserChanged,
|
|
||||||
saveUserData,
|
|
||||||
loadUserData
|
|
||||||
};
|
|
||||||
+31
-11
@@ -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) {
|
export function creerFormation(brut) {
|
||||||
|
|
||||||
|
// Calcul systématique du taux d'accès : pourcentage = (admis / candidats) * 100
|
||||||
var taux = 0;
|
var taux = 0;
|
||||||
var latitude = null;
|
|
||||||
var longitude = null;
|
|
||||||
|
|
||||||
if (brut.voe_tot && brut.voe_tot > 0) {
|
if (brut.voe_tot && brut.voe_tot > 0) {
|
||||||
taux = Math.round((brut.acc_tot / brut.voe_tot) * 100);
|
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) {
|
if (brut.g_olocalisation_des_formations) {
|
||||||
latitude = brut.g_olocalisation_des_formations.lat;
|
latitude = brut.g_olocalisation_des_formations.lat;
|
||||||
longitude = brut.g_olocalisation_des_formations.lon;
|
longitude = brut.g_olocalisation_des_formations.lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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,
|
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,
|
nom: brut.lib_for_voe_ins,
|
||||||
etablissement: brut.g_ea_lib_vx,
|
etablissement: brut.g_ea_lib_vx,
|
||||||
ville: brut.ville_etab,
|
ville: brut.ville_etab,
|
||||||
@@ -26,40 +39,47 @@ export function creerFormation(brut) {
|
|||||||
academie: brut.acad_mies,
|
academie: brut.acad_mies,
|
||||||
contrat: brut.contrat_etab,
|
contrat: brut.contrat_etab,
|
||||||
|
|
||||||
|
// Cursus et niveau
|
||||||
filiere: brut.fili,
|
filiere: brut.fili,
|
||||||
selectivite: brut.select_form,
|
selectivite: brut.select_form,
|
||||||
|
|
||||||
|
// Statistiques principales d'admission
|
||||||
capacite: brut.capa_fin,
|
capacite: brut.capa_fin,
|
||||||
candidats: brut.voe_tot,
|
candidats: brut.voe_tot,
|
||||||
admis: brut.acc_tot,
|
admis: brut.acc_tot,
|
||||||
tauxAcces: taux,
|
tauxAcces: taux,
|
||||||
|
|
||||||
latitude: latitude,
|
// Placement sur la carte
|
||||||
longitude: longitude,
|
latitude,
|
||||||
|
longitude,
|
||||||
|
|
||||||
|
// Indicateurs du profil étudiant entrant (sociologique)
|
||||||
pctFemmes: brut.pct_f,
|
pctFemmes: brut.pct_f,
|
||||||
pctBoursiers: brut.pct_bours,
|
pctBoursiers: brut.pct_bours,
|
||||||
pctNeoBac: brut.pct_neobac,
|
pctNeoBac: brut.pct_neobac,
|
||||||
|
|
||||||
|
// Origine académique par type du Bac précédent
|
||||||
pctGeneral: brut.pct_bg,
|
pctGeneral: brut.pct_bg,
|
||||||
pctTechno: brut.pct_bt,
|
pctTechno: brut.pct_bt,
|
||||||
pctPro: brut.pct_bp,
|
pctPro: brut.pct_bp,
|
||||||
|
|
||||||
|
// Niveaux scolaires par les mentions obtenues
|
||||||
pctSansMention: brut.pct_sansmention,
|
pctSansMention: brut.pct_sansmention,
|
||||||
pctAB: brut.pct_ab,
|
pctAB: brut.pct_ab,
|
||||||
pctB: brut.pct_b,
|
pctB: brut.pct_b,
|
||||||
pctTB: brut.pct_tb,
|
pctTB: brut.pct_tb,
|
||||||
pctTBF: brut.pct_tbf,
|
pctTBF: brut.pct_tbf,
|
||||||
|
|
||||||
pctDebutPhase: brut.pct_acc_debutpp,
|
// Dynamique temporelle : comment la formation s'est remplie durant la procédure
|
||||||
pctDateBac: brut.pct_acc_datebac,
|
pctDebutPhase: brut.pct_acc_debutpp, // 30 mai (lancement)
|
||||||
pctFinPhase: brut.pct_acc_finpp,
|
pctDateBac: brut.pct_acc_datebac, // 16 juin (résultats)
|
||||||
|
pctFinPhase: brut.pct_acc_finpp, // 11 juillet
|
||||||
|
|
||||||
admisDebutPhase: brut.acc_debutpp,
|
admisDebutPhase: brut.acc_debutpp,
|
||||||
admisDateBac: brut.acc_datebac,
|
admisDateBac: brut.acc_datebac,
|
||||||
admisFinPhase: brut.acc_finpp,
|
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,
|
voePPGeneral: brut.nb_voe_pp_bg,
|
||||||
voePPTechno: brut.nb_voe_pp_bt,
|
voePPTechno: brut.nb_voe_pp_bt,
|
||||||
voePPPro: brut.nb_voe_pp_bp,
|
voePPPro: brut.nb_voe_pp_bp,
|
||||||
@@ -84,7 +104,7 @@ export function creerFormation(brut) {
|
|||||||
acceptesPPAutres: brut.acc_at,
|
acceptesPPAutres: brut.acc_at,
|
||||||
acceptesPPTotal: brut.acc_pp,
|
acceptesPPTotal: brut.acc_pp,
|
||||||
|
|
||||||
// Phase complémentaire
|
// Détails de la Phase Complémentaire — Session de rattrapage
|
||||||
voePCGeneral: brut.nb_voe_pc_bg,
|
voePCGeneral: brut.nb_voe_pc_bg,
|
||||||
voePCTechno: brut.nb_voe_pc_bt,
|
voePCTechno: brut.nb_voe_pc_bt,
|
||||||
voePCPro: brut.nb_voe_pc_bp,
|
voePCPro: brut.nb_voe_pc_bp,
|
||||||
|
|||||||
+35
-24
@@ -1,20 +1,34 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Parcoursup Explorer</title>
|
<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="./style.css" />
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.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" />
|
<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://cdn.jsdelivr.net/npm/riot@9/riot+compiler.min.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Point de montage de l'application : le composant <app> va y être instancié -->
|
||||||
<app></app>
|
<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/search-bar.riot" type="riot"></script>
|
||||||
<script src="./components/result-list.riot" type="riot"></script>
|
<script src="./components/result-list.riot" type="riot"></script>
|
||||||
<script src="./components/detail-view.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="./components/comparateur.riot" type="riot"></script>
|
||||||
<script src="./app.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">
|
<script type="module">
|
||||||
import { chargerFormations, chargerHistoriqueFormation } from './api.js'
|
import { chargerFormations, chargerHistoriqueFormation } from './api.js'
|
||||||
import { creerFormation } from './formation.js'
|
import { creerFormation } from './formation.js'
|
||||||
import {
|
import { auth, db, createAccount, login, logout, onUserChanged, saveUserData, loadUserData } from './firebase.js'
|
||||||
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.chargerFormations = chargerFormations
|
||||||
window.creerFormation = creerFormation
|
window.creerFormation = creerFormation
|
||||||
window.chargerHistoriqueFormation = chargerHistoriqueFormation
|
window.chargerHistoriqueFormation = chargerHistoriqueFormation
|
||||||
|
|
||||||
window.firebaseServices = {
|
window.firebaseServices = { auth, db, createAccount, login, logout, onUserChanged, saveUserData, loadUserData }
|
||||||
auth,
|
|
||||||
db,
|
|
||||||
createAccount,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
onUserChanged,
|
|
||||||
saveUserData,
|
|
||||||
loadUserData
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Ordre des opérations : 1. Compilation des composants 2. Montage du composant principal
|
||||||
await riot.compile()
|
await riot.compile()
|
||||||
riot.mount('app')
|
riot.mount('app')
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user