/* ================================================================== Moteur d'exercices hors ligne — 2e commune ------------------------------------------------------------------ Aucune dépendance externe. Chaque page de bloc déclare ses exercices dans window.DATA, un tableau d'objets. Types reconnus : qcm, vf, assoc, tri, ordre, hotspot, calcul, texte, intrus, chrono Règle de comptage, la même pour tous les types : un exercice compte pour un point, une seule fois, au moment où il est terminé, et il est réussi s'il a été terminé sans erreur. Recommencer une série chronométrée ne réajoute donc pas de point. ================================================================== */ "use strict"; var TOTAL = 0, REUSSIS = 0, TERMINES = 0; /* ---------- petits utilitaires ---------- */ function norm(s){ return (s || "").toString().toLowerCase() .normalize("NFD").replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]/g, ""); } function el(tag, cls, txt){ var e = document.createElement(tag); if (cls) e.className = cls; if (txt !== undefined) e.textContent = txt; return e; } function melange(t){ var a = t.slice(); for (var i = a.length - 1; i > 0; i--){ var j = Math.floor(Math.random() * (i + 1)); var x = a[i]; a[i] = a[j]; a[j] = x; } return a; } /* ---------- score ---------- */ function majScore(){ var s = document.getElementById("score"); if (!s) return; var pct = TOTAL ? Math.round(REUSSIS / TOTAL * 100) : 0; s.querySelector(".score__txt").innerHTML = "Exercices réussis : " + REUSSIS + " / " + TOTAL + " (" + pct + " %)" + (TERMINES ? " — " + TERMINES + " terminé" + (TERMINES > 1 ? "s" : "") : ""); s.querySelector(".jauge span").style.width = pct + "%"; } /* Clôture un exercice : affiche le verdict et le compte, une seule fois. Un exercice déjà clos peut encore être rejoué (série chronométrée), mais il ne change plus le score. */ function clore(box, reussi, msg){ var r = box.querySelector(".retour"); r.className = "retour " + (reussi ? "retour--ok" : "retour--ko"); r.textContent = (reussi ? "Réussi. " : "Terminé, mais avec des erreurs. ") + (msg || ""); if (box.dataset.compte === "1") return; box.dataset.compte = "1"; TERMINES++; if (reussi) REUSSIS++; majScore(); } /* Message intermédiaire : ne clôt pas l'exercice, ne compte pas. */ function signaler(box, ok, msg){ var r = box.querySelector(".retour"); r.className = "retour " + (ok ? "retour--ok" : "retour--ko"); r.textContent = msg; } /* ---------- cadre commun ---------- */ function cadre(d){ var box = el("article", "ex"); box.appendChild(el("p", "consigne", d.consigne || "Exercice")); if (d.enonce) box.appendChild(el("p", "enonce", d.enonce)); return box; } function finir(box){ /* role="status" : le verdict est annoncé aux lecteurs d'écran sans déplacer le focus de l'élève. */ var r = el("div", "retour"); r.setAttribute("role", "status"); r.setAttribute("aria-live", "polite"); box.appendChild(r); return box; } /* ---------- QCM ---------- */ function faireQcm(d){ var box = cadre(d), fini = false; d.options.forEach(function(txt, i){ var b = el("button", "opt", txt); b.type = "button"; b.onclick = function(){ if (fini) return; fini = true; var ok = (i === d.reponse); b.classList.add(ok ? "juste" : "faux"); if (!ok) box.querySelectorAll(".opt")[d.reponse].classList.add("juste"); box.querySelectorAll(".opt").forEach(function(o){ o.disabled = true; }); clore(box, ok, d.explication || ""); }; box.appendChild(b); }); return finir(box); } /* ---------- Vrai / Faux ---------- */ function faireVf(d){ var box = cadre(d), fini = false; ["Vrai", "Faux"].forEach(function(txt, i){ var b = el("button", "jeton", txt); b.type = "button"; b.onclick = function(){ if (fini) return; fini = true; var ok = ((i === 0) === d.reponse); b.classList.add(ok ? "juste" : "faux"); box.querySelectorAll(".jeton").forEach(function(o){ o.disabled = true; }); clore(box, ok, d.explication || ""); }; box.appendChild(b); }); return finir(box); } /* ---------- Association : un clic à gauche, un clic à droite ---------- */ function faireAssoc(d){ var box = cadre(d); var zone = el("div", "deux-col"); var g = el("div"), dr = el("div"); g.appendChild(el("h4", null, "Colonne A")); dr.appendChild(el("h4", null, "Colonne B")); var choisi = null, trouves = 0, erreurs = 0; var droite = melange(d.paires.map(function(p, i){ return { t: p[1], i: i }; })); d.paires.forEach(function(p, i){ var b = el("button", "jeton jeton--bloc", p[0]); b.type = "button"; b.dataset.i = i; b.onclick = function(){ if (b.disabled) return; if (choisi) choisi.classList.remove("choisi"); choisi = b; b.classList.add("choisi"); }; g.appendChild(b); }); droite.forEach(function(o){ var b = el("button", "jeton jeton--bloc", o.t); b.type = "button"; b.onclick = function(){ if (b.disabled) return; if (!choisi){ signaler(box, false, "Choisis d'abord une étiquette dans la colonne A."); return; } var ok = (parseInt(choisi.dataset.i, 10) === o.i); if (ok){ choisi.classList.remove("choisi"); choisi.classList.add("juste"); b.classList.add("juste"); choisi.disabled = true; b.disabled = true; choisi = null; trouves++; if (trouves === d.paires.length){ clore(box, erreurs === 0, (d.explication || "") + (erreurs ? " Erreurs : " + erreurs + "." : "")); } else { signaler(box, true, "Bonne association. Il en reste " + (d.paires.length - trouves) + "."); } } else { erreurs++; b.classList.add("faux"); setTimeout(function(){ b.classList.remove("faux"); }, 600); signaler(box, false, "Ce n'est pas la bonne correspondance, essaie autre chose."); } }; dr.appendChild(b); }); zone.appendChild(g); zone.appendChild(dr); box.appendChild(zone); return finir(box); } /* ---------- Tri dans des paniers ---------- Les paniers sont des div : un bouton ne peut pas contenir les étiquettes déposées, qui sont elles-mêmes des boutons. On leur donne donc explicitement le rôle, la tabulation et les touches Entrée et Espace, pour qu'ils soient utilisables au clavier. */ function faireTri(d){ var box = cadre(d); var reserve = el("div", "reserve"); var choisi = null, places = 0, erreurs = 0; melange(d.items).forEach(function(it){ var b = el("button", "item", it[0]); b.type = "button"; b.dataset.p = it[1]; b.onclick = function(){ if (b.disabled) return; if (choisi) choisi.classList.remove("choisi"); choisi = b; b.classList.add("choisi"); signaler(box, true, "« " + it[0] + " » est sélectionné. Choisis maintenant son panier."); }; reserve.appendChild(b); }); box.appendChild(reserve); var paniers = el("div", "paniers"); d.paniers.forEach(function(nom, pi){ var p = el("div", "panier"); p.tabIndex = 0; p.setAttribute("role", "button"); p.setAttribute("aria-label", "Panier " + nom); p.appendChild(el("h4", null, nom)); function deposer(){ if (!choisi){ signaler(box, false, "Choisis d'abord une étiquette dans la réserve."); return; } var ok = (parseInt(choisi.dataset.p, 10) === pi); if (ok){ choisi.classList.remove("choisi"); choisi.classList.add("juste"); choisi.disabled = true; p.appendChild(choisi); choisi = null; places++; if (places === d.items.length){ clore(box, erreurs === 0, (d.explication || "") + (erreurs ? " Erreurs : " + erreurs + "." : "")); } else { signaler(box, true, "Bien classé. Il en reste " + (d.items.length - places) + "."); } } else { erreurs++; p.classList.add("vise"); setTimeout(function(){ p.classList.remove("vise"); }, 500); signaler(box, false, "Ce n'est pas le bon panier."); } } p.onclick = deposer; p.onkeydown = function(e){ if (e.key === "Enter" || e.key === " " || e.key === "Spacebar"){ e.preventDefault(); deposer(); } }; paniers.appendChild(p); }); box.appendChild(paniers); return finir(box); } /* ---------- Remise en ordre ---------- */ function faireOrdre(d){ var box = cadre(d); var liste = el("div", "liste-ordre"); var ordre = melange(d.etapes.map(function(t, i){ return { t: t, i: i }; })); var fini = false, essais = 0; function dessiner(){ liste.innerHTML = ""; ordre.forEach(function(o, pos){ var ligne = el("div", "item item--bloc"); ligne.appendChild(document.createTextNode((pos + 1) + ". " + o.t + " ")); var up = el("button", "jeton jeton--mini", "monter"); var dn = el("button", "jeton jeton--mini", "descendre"); up.type = dn.type = "button"; up.setAttribute("aria-label", "Monter : " + o.t); dn.setAttribute("aria-label", "Descendre : " + o.t); up.disabled = (pos === 0); dn.disabled = (pos === ordre.length - 1); up.onclick = function(){ var x = ordre[pos - 1]; ordre[pos - 1] = ordre[pos]; ordre[pos] = x; dessiner(); }; dn.onclick = function(){ var x = ordre[pos + 1]; ordre[pos + 1] = ordre[pos]; ordre[pos] = x; dessiner(); }; ligne.appendChild(up); ligne.appendChild(dn); liste.appendChild(ligne); }); } dessiner(); box.appendChild(liste); var v = el("button", "action", "Vérifier l'ordre"); v.type = "button"; v.onclick = function(){ if (fini) return; var ok = ordre.every(function(o, pos){ return o.i === pos; }); if (ok){ fini = true; v.disabled = true; liste.querySelectorAll(".item").forEach(function(i){ i.classList.add("juste"); }); clore(box, essais === 0, (d.explication || "") + (essais ? " Vérifications ratées : " + essais + "." : "")); } else { essais++; signaler(box, false, "L'ordre n'est pas encore correct, déplace encore des lignes."); } }; box.appendChild(v); return finir(box); } /* ---------- Zones cliquables sur schéma ---------- Les zones SVG reçoivent tabindex et role pour être atteignables au clavier, comme les boutons du reste de la page. */ function faireHotspot(d){ var box = cadre(d); var wrap = el("div", "schema"); wrap.innerHTML = d.svg; box.appendChild(wrap); var question = el("p", "enonce"); question.setAttribute("role", "status"); question.setAttribute("aria-live", "polite"); box.appendChild(question); var restants = melange(d.zones.slice()); var erreurs = 0; function demander(){ if (!restants.length){ question.textContent = "Terminé."; clore(box, erreurs === 0, (d.explication || "") + (erreurs ? " Erreurs : " + erreurs + "." : "")); return; } question.innerHTML = "Clique sur : " + restants[0] + ""; } wrap.querySelectorAll(".zone").forEach(function(z){ z.setAttribute("tabindex", "0"); z.setAttribute("role", "button"); z.setAttribute("aria-label", z.dataset.zone || "zone"); function essayer(){ if (!restants.length) return; if (z.dataset.zone === restants[0]){ z.classList.add("juste"); restants.shift(); demander(); } else { erreurs++; z.classList.add("faux"); setTimeout(function(){ z.classList.remove("faux"); }, 500); signaler(box, false, "Ce n'est pas cet élément."); } } z.addEventListener("click", essayer); z.addEventListener("keydown", function(e){ if (e.key === "Enter" || e.key === " " || e.key === "Spacebar"){ e.preventDefault(); essayer(); } }); }); demander(); return finir(box); } /* ---------- Calcul ou réponse courte ---------- */ function faireSaisie(d){ var box = cadre(d); var champ = el("input", "court"); champ.type = "text"; champ.setAttribute("aria-label", d.enonce || d.consigne || "Réponse"); var v = el("button", "action", "Vérifier"); v.type = "button"; var fini = false, essais = 0; function verifier(){ if (fini) return; var ok; if (d.type === "calcul"){ var n = parseFloat(champ.value.replace(",", ".").replace(/[^0-9.\-]/g, "")); ok = !isNaN(n) && Math.abs(n - d.reponse) <= (d.tolerance || 0); } else { ok = d.acceptees.some(function(a){ return norm(a) === norm(champ.value); }); } if (ok){ fini = true; v.disabled = true; champ.disabled = true; clore(box, essais === 0, (d.explication || "") + (essais ? " Essais ratés : " + essais + "." : "")); } else { essais++; signaler(box, false, d.indice || "Relis la théorie du chapitre et réessaie."); } } v.onclick = verifier; champ.onkeydown = function(e){ if (e.key === "Enter"){ e.preventDefault(); verifier(); } }; box.appendChild(champ); box.appendChild(document.createTextNode(" ")); box.appendChild(v); return finir(box); } /* ---------- Chasse aux affirmations fausses ---------- */ function faireIntrus(d){ var box = cadre(d); var restants = d.items.filter(function(i){ return i[1]; }).length; var erreurs = 0; d.items.forEach(function(it){ var b = el("button", "opt", it[0]); b.type = "button"; b.onclick = function(){ if (b.disabled) return; b.disabled = true; if (it[1]){ b.classList.add("juste"); restants--; if (restants === 0){ clore(box, erreurs === 0, (d.explication || "") + (erreurs ? " Affirmations correctes cliquées par erreur : " + erreurs + "." : "")); } else { signaler(box, true, "Bien vu. Il reste " + restants + " affirmation" + (restants > 1 ? "s" : "") + " fausse" + (restants > 1 ? "s" : "") + "."); } } else { b.classList.add("faux"); erreurs++; signaler(box, false, "Cette affirmation est correcte, ce n'est pas un intrus."); } }; box.appendChild(b); }); return finir(box); } /* ---------- Série chronométrée ---------- Le premier passage fixe le score de l'exercice. Les suivants servent à s'entraîner : ils affichent un résultat mais ne touchent plus au compteur de la page. */ function faireChrono(d){ var box = cadre(d); var minuteur = el("span", "minuteur", ""); minuteur.setAttribute("role", "timer"); box.querySelector(".consigne").appendChild(minuteur); var zone = el("div"); box.appendChild(zone); var idx = 0, bons = 0, temps = d.secondes || 60, timer = null, encours = false; var demarrer = el("button", "action", "Démarrer la série"); demarrer.type = "button"; box.appendChild(demarrer); demarrer.onclick = function(){ if (encours) return; encours = true; demarrer.hidden = true; idx = 0; bons = 0; temps = d.secondes || 60; minuteur.textContent = temps + " s"; timer = setInterval(function(){ temps--; minuteur.textContent = temps + " s"; if (temps <= 0) terminer(); }, 1000); poser(); }; function poser(){ zone.innerHTML = ""; if (idx >= d.questions.length){ terminer(); return; } var q = d.questions[idx]; zone.appendChild(el("p", "enonce", (idx + 1) + ". " + q.enonce)); q.options.forEach(function(t, i){ var b = el("button", "jeton", t); b.type = "button"; b.onclick = function(){ if (!encours) return; if (i === q.reponse) bons++; idx++; poser(); }; zone.appendChild(b); }); } function terminer(){ clearInterval(timer); timer = null; encours = false; zone.innerHTML = ""; var seuil = Math.ceil(d.questions.length * 0.7); var ok = bons >= seuil; var deja = (box.dataset.compte === "1"); var detail = "Score : " + bons + " / " + d.questions.length + ". Seuil de réussite : " + seuil + "."; if (deja){ /* Déjà compté au premier passage : on informe sans toucher au score. */ signaler(box, ok, detail + " Ce nouvel essai ne change plus le compteur de la page."); } else { clore(box, ok, detail); } demarrer.textContent = "Recommencer la série"; demarrer.hidden = false; } return finir(box); } /* ---------- Construction de la page ---------- */ var FABRIQUES = { qcm: faireQcm, vf: faireVf, assoc: faireAssoc, tri: faireTri, ordre: faireOrdre, hotspot: faireHotspot, calcul: faireSaisie, texte: faireSaisie, intrus: faireIntrus, chrono: faireChrono }; function construire(data){ var cible = document.getElementById("exercices"); if (!cible) return; TOTAL = data.length; REUSSIS = 0; TERMINES = 0; var s = el("div", "score"); s.id = "score"; s.appendChild(el("div", "score__txt")); var jauge = el("div", "jauge"); jauge.appendChild(el("span")); s.appendChild(jauge); var rz = el("button", "action action--secondaire", "Tout recommencer"); rz.type = "button"; rz.onclick = function(){ location.reload(); }; s.appendChild(rz); cible.parentNode.insertBefore(s, cible); data.forEach(function(d){ var f = FABRIQUES[d.type]; if (f) cible.appendChild(f(d)); }); majScore(); } window.addEventListener("DOMContentLoaded", function(){ if (window.DATA) construire(window.DATA); });