Compare commits
26 Commits
0b4c2244fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b818414ed | |||
| f027dfcf4c | |||
| 34216a52f9 | |||
| f611bdcd7e | |||
| f7c846c85c | |||
| a74df51201 | |||
| 42650ca351 | |||
| 21a7359057 | |||
| 6bb7985512 | |||
| 090cbc19a2 | |||
| 794ae69fa0 | |||
| 88b075fb88 | |||
| b5f2eac842 | |||
| a42957ad41 | |||
| 421ae4c2bf | |||
| affa042c05 | |||
| 30817c829b | |||
| 41d8b98f96 | |||
| d251782842 | |||
| 66ed3c505f | |||
| 543bb6cd52 | |||
| a2a4fea004 | |||
| 8164b03cc4 | |||
| d351ee6efb | |||
| 317f98b711 | |||
| 2a85ced1e6 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Contient le mot de passe SQL Server — à éditer uniquement sur bsite.net
|
||||
# Contient le mot de passe de la base SQL Server — à créer/éditer uniquement sur
|
||||
# le serveur bsite.net à partir de web.config.example (ne jamais versionner)
|
||||
exercices/memory-pictogrammes/api/web.config
|
||||
|
||||
BIN
data/ghs05-corrosif.jpg
Normal file
BIN
data/ghs05-corrosif.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
data/ghs07-nocif.jpg
Normal file
BIN
data/ghs07-nocif.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
data/ghs09-environnement.jpg
Normal file
BIN
data/ghs09-environnement.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -17,7 +17,7 @@
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding-top: calc(70px + 2rem);
|
||||
padding-bottom: calc(52px + 2rem);
|
||||
padding-bottom: calc(84px + 2rem);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
overflow-y: auto;
|
||||
@@ -32,35 +32,58 @@
|
||||
|
||||
.progress-fill { background: var(--amber); transition: width 0.4s ease; }
|
||||
|
||||
/* PHASE INDICATOR */
|
||||
.phase-badge {
|
||||
position: fixed;
|
||||
top: 80px; right: 16px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* BOTTOM BAR */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 52px;
|
||||
height: 84px;
|
||||
background: rgba(250, 238, 218, 0.98);
|
||||
border-top: 2px solid var(--amber);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.slide-counter { font-size: 12px; font-family: 'Syne', sans-serif; font-weight: 600; color: var(--amber-dark); }
|
||||
.bottom-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.footer-btn {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 11px 24px;
|
||||
border-radius: 40px;
|
||||
border: 1.5px solid var(--amber);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.footer-btn:hover { transform: translateY(-1px); }
|
||||
.footer-btn:active { transform: scale(0.98); }
|
||||
.footer-btn.is-primary { background: var(--amber); color: #fff; }
|
||||
.footer-btn.is-primary:hover { background: var(--amber-dark); }
|
||||
.footer-btn.is-secondary { background: transparent; color: var(--amber-dark); }
|
||||
.footer-btn.is-secondary:hover { background: rgba(186,117,23,0.08); }
|
||||
.slide-counter {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
font-size: 12px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 600;
|
||||
color: var(--amber-dark);
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.slide-counter { display: none; }
|
||||
}
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 0.5px solid var(--amber);
|
||||
@@ -383,6 +406,15 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.identity-fields input:focus { outline: none; border-color: var(--amber); background: white; }
|
||||
.formateur-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); margin: 4px 0 2px; }
|
||||
.formateur-row { display: flex; gap: 8px; }
|
||||
.formateur-btn {
|
||||
flex: 1; font-family: 'DM Sans', sans-serif; font-size: 13.5px;
|
||||
padding: 9px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg); color: var(--text); cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.formateur-btn:hover { border-color: var(--amber); }
|
||||
.formateur-btn.active { background: var(--amber); border-color: var(--amber); color: #fff; }
|
||||
|
||||
/* DECO */
|
||||
.deco-circle { position: fixed; border-radius: 50%; pointer-events: none; z-index: 0; }
|
||||
@@ -411,15 +443,19 @@
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
|
||||
<!-- Phase badge -->
|
||||
<div class="phase-badge" id="phaseBadge" style="display:none"></div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="bottom-bar" id="bottomBar" style="display:none;">
|
||||
<button class="back-btn" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
|
||||
<div class="slide-counter" id="slideCounter"></div>
|
||||
<div class="bottom-actions">
|
||||
<button class="footer-btn is-secondary" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
|
||||
<button class="footer-btn is-secondary" id="footerHintBtn" onclick="showHintCurrent()" style="display:none;">💡 Voir les indications</button>
|
||||
<button class="footer-btn is-primary" id="footerNextBtn" onclick="goNext()" style="display:none;">Exercice suivant →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confetti -->
|
||||
<canvas id="confettiCanvas" style="position:fixed;inset:0;pointer-events:none;z-index:150;display:none;"></canvas>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
|
||||
<div class="modal" id="modal">
|
||||
@@ -448,7 +484,11 @@
|
||||
<div class="section-tag">Avant de commencer</div>
|
||||
<div class="slide-title">Contexte & accès</div>
|
||||
<p class="slide-intro">BioCID est la plateforme officielle de l'ANSES pour consulter tous les produits biocides autorisés et déclarés en France. Cet exercice développe votre autonomie dans la consultation des produits.</p>
|
||||
<div class="resource-card">
|
||||
<div class="info-card" style="max-width:620px;">
|
||||
<span>🎯</span>
|
||||
<div>4 exercices vous attendent : recherche par <strong>nom</strong>, par <strong>type</strong>, par <strong>substance active</strong>, puis un <strong>cas de synthèse</strong>. Durée totale ≈ 30 min.</div>
|
||||
</div>
|
||||
<div class="resource-card" style="max-width:620px;margin-top:0.5rem;">
|
||||
<div class="resource-icon">🌐</div>
|
||||
<div class="resource-body">
|
||||
<div class="resource-title">Plateforme BioCID — ANSES</div>
|
||||
@@ -456,27 +496,38 @@
|
||||
</div>
|
||||
<a href="https://biocid-anses.fr/biocid" target="_blank" class="resource-link">Ouvrir ↗</a>
|
||||
</div>
|
||||
<div class="info-card" style="margin-top:0.5rem;">
|
||||
<span>🎯</span>
|
||||
<div>4 exercices vous attendent : recherche par <strong>nom</strong>, par <strong>type</strong>, par <strong>substance active</strong>, puis un <strong>cas de synthèse</strong>. Durée totale ≈ 30 min.</div>
|
||||
</div>
|
||||
<div class="identity-card">
|
||||
<div class="identity-card" style="max-width:620px;">
|
||||
<div class="identity-title">👥 Identification du groupe</div>
|
||||
<div class="identity-fields">
|
||||
<input type="text" id="idGroupe" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off">
|
||||
<input type="text" id="idGroupe" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off" oninput="clearGroupeWarning()">
|
||||
<input type="date" id="idDate">
|
||||
<input type="text" id="idFormateur" placeholder="Formateur (facultatif)" autocomplete="off">
|
||||
<input type="hidden" id="idFormateur">
|
||||
<div class="formateur-label">Formateur de la session</div>
|
||||
<div class="formateur-row">
|
||||
<button type="button" class="formateur-btn" data-formateur="Nathan" onclick="selectFormateur(this)">Nathan</button>
|
||||
<button type="button" class="formateur-btn" data-formateur="Gauthier" onclick="selectFormateur(this)">Gauthier</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="idWarning" style="display:none;margin-top:8px;font-size:12.5px;color:var(--coral);">⚠ Renseignez le nom du groupe pour continuer.</div>
|
||||
</div>
|
||||
<button class="btn btn-amber" onclick="goTo(2)" style="margin-top:1.5rem;">Commencer l'exercice 1 →</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 2 — EXERCICE 1 ============ -->
|
||||
<!-- ============ SLIDE 2 — EXERCICE 1 : INTRO ============ -->
|
||||
<div class="slide" id="slide-2">
|
||||
<div class="deco-circle" style="width:280px;height:280px;background:var(--amber-light);top:-40px;right:-40px;opacity:0.5;"></div>
|
||||
<div class="section-tag">Exercice 1 / 4 — 5 min</div>
|
||||
<div class="slide-title">🔎 Recherche par nom de produit</div>
|
||||
<p class="slide-intro">La recherche par nom est le chemin le plus direct dans BioCID : elle donne accès à la <strong>fiche complète</strong> d'un produit déjà identifié — son <strong>type</strong>, sa <strong>substance active</strong>, son <strong>fabricant</strong> et ses <strong>conditions d'utilisation</strong>.</p>
|
||||
<div class="info-card">
|
||||
<span>🎯</span>
|
||||
<div>Pour chaque question, saisissez votre réponse dans le champ prévu. Vos réponses sont <strong>enregistrées automatiquement</strong> et reprises dans le PDF final.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 3 — EXERCICE 1 : QUESTIONS ============ -->
|
||||
<div class="slide" id="slide-3">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag">Exercice 1 / 4 — 5 min</div>
|
||||
<div class="slide-title">Recherche par nom de produit</div>
|
||||
<p class="slide-intro">Trouvez un produit spécifique et relevez ses caractéristiques principales.</p>
|
||||
<ul class="questions-list">
|
||||
<li class="q-item">
|
||||
<div class="q-head"><span class="q-num">1</span><span>Recherchez un produit connu (ex. « Goliath », « Phobi », « Soricide »). Notez son <strong>type de produit</strong>, sa <strong>substance active</strong> et son <strong>fabricant</strong>.</span></div>
|
||||
@@ -491,19 +542,15 @@
|
||||
<textarea class="answer-field" id="bio_ex1_3" placeholder="Votre réponse…" data-question="Ex1 — Conditions d'utilisation"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('ex1')">💡 Voir les indications</button>
|
||||
<button class="btn btn-amber" onclick="goTo(3)">Exercice suivant →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 3 — EXERCICE 2 ============ -->
|
||||
<div class="slide" id="slide-3">
|
||||
<!-- ============ SLIDE 4 — EXERCICE 2 ============ -->
|
||||
<div class="slide" id="slide-4">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag">Exercice 2 / 4 — 5 min</div>
|
||||
<div class="slide-title">Recherche par type de produit</div>
|
||||
<p class="slide-intro">Explorez les catégories disponibles à l'aide des filtres par code TP (Type Product).</p>
|
||||
<p class="slide-intro">Explorez les catégories disponibles à l'aide des filtres par TP.</p>
|
||||
<ul class="questions-list">
|
||||
<li class="q-item">
|
||||
<div class="q-head"><span class="q-num">1</span><span>Utilisez le filtre « Type de produit » et sélectionnez « Rodenticides (TP14) » ou « Insecticides (TP18) ». Combien de produits sont disponibles ?</span></div>
|
||||
@@ -514,15 +561,11 @@
|
||||
<textarea class="answer-field" id="bio_ex2_2" placeholder="Votre réponse…" data-question="Ex2 — Comparaison entre catégories"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('ex2')">💡 Voir les indications</button>
|
||||
<button class="btn btn-amber" onclick="goTo(4)">Exercice suivant →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 4 — EXERCICE 3 ============ -->
|
||||
<div class="slide" id="slide-4">
|
||||
<!-- ============ SLIDE 5 — EXERCICE 3 ============ -->
|
||||
<div class="slide" id="slide-5">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag">Exercice 3 / 4 — 5 min</div>
|
||||
<div class="slide-title">Recherche par substance active</div>
|
||||
@@ -537,21 +580,17 @@
|
||||
<textarea class="answer-field" id="bio_ex3_2" placeholder="Votre réponse…" data-question="Ex3 — Formulations et concentrations comparées"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('ex3')">💡 Voir les indications</button>
|
||||
<button class="btn btn-amber" onclick="goTo(5)">Cas de synthèse →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 5 — EXERCICE 4 (CAS DE SYNTHÈSE) ============ -->
|
||||
<div class="slide" id="slide-5">
|
||||
<!-- ============ SLIDE 6 — EXERCICE 4 (CAS DE SYNTHÈSE) ============ -->
|
||||
<div class="slide" id="slide-6">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag" style="background:var(--coral-light);color:var(--coral);">Exercice 4 / 4 — Cas de synthèse</div>
|
||||
<div class="slide-title">Mise en situation réelle</div>
|
||||
<div class="slide-title">Mise en situation</div>
|
||||
<div class="info-card" style="background:var(--coral-light);color:var(--coral);margin-bottom:1rem;">
|
||||
<span>📞</span>
|
||||
<div>Un restaurant vous appelle pour une demande de produit. En utilisant BioCID, présentez une <strong>recommandation argumentée</strong>.</div>
|
||||
<div>Un restaurant vous appelle au sujet de votre prestation et souhaite choisir le produit entre plusieurs références. En utilisant la plateforme BioCID, présentez une <strong>recommandation argumentée</strong>.</div>
|
||||
</div>
|
||||
<ul class="questions-list">
|
||||
<li class="q-item">
|
||||
@@ -567,14 +606,11 @@
|
||||
<textarea class="answer-field" id="bio_ex4_3" placeholder="Votre réponse…" data-question="Ex4 — Argumentaire (+ bonus FDS)"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="goTo(6)">Voir les bonnes pratiques →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 6 — ASTUCES / FIN ============ -->
|
||||
<div class="slide" id="slide-6">
|
||||
<!-- ============ SLIDE 7 — ASTUCES / FIN ============ -->
|
||||
<div class="slide" id="slide-7">
|
||||
<div class="scroll-area">
|
||||
<div class="deco-circle" style="width:320px;height:320px;background:var(--amber-light);bottom:-80px;right:-60px;opacity:0.5;"></div>
|
||||
<div style="text-align:center;">
|
||||
@@ -592,10 +628,7 @@
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
|
||||
<a href="correction-biocid.html" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
</div>
|
||||
<div class="actions-row" style="margin-top:0.5rem;">
|
||||
<a href="https://biocid-anses.fr/biocid" target="_blank" class="btn btn-outline">biocid-anses.fr ↗</a>
|
||||
<a href="corriger-exercice.html?exo=biocid" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
<button class="btn btn-outline" onclick="restartAll()">↺ Recommencer</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -604,7 +637,17 @@
|
||||
<script>
|
||||
let currentSlide = 0;
|
||||
let history = [];
|
||||
const totalSlides = 6;
|
||||
const totalSlides = 7;
|
||||
|
||||
// Configuration des boutons du footer par slide
|
||||
const slideConfig = {
|
||||
1: { next: 2, nextLabel: "Commencer l'exercice 1 →" },
|
||||
2: { next: 3, nextLabel: 'Aux questions →' },
|
||||
3: { hint: 'ex1', next: 4, nextLabel: 'Exercice suivant →' },
|
||||
4: { hint: 'ex2', next: 5, nextLabel: 'Exercice suivant →' },
|
||||
5: { hint: 'ex3', next: 6, nextLabel: 'Cas de synthèse →' },
|
||||
6: { next: 7, nextLabel: "Terminer l'exercice →" }
|
||||
};
|
||||
|
||||
const hints = {
|
||||
ex1: {
|
||||
@@ -637,6 +680,7 @@ function goTo(n) {
|
||||
const sa = next.querySelector('.scroll-area');
|
||||
if (sa) sa.scrollTop = 0;
|
||||
updateUI();
|
||||
if (n === 7) setTimeout(launchConfetti, 200);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -652,28 +696,57 @@ function goBack() {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function clearGroupeWarning() {
|
||||
const g = document.getElementById('idGroupe');
|
||||
if (g) g.style.borderColor = '';
|
||||
const w = document.getElementById('idWarning');
|
||||
if (w) w.style.display = 'none';
|
||||
}
|
||||
|
||||
function selectFormateur(btn) {
|
||||
document.querySelectorAll('.formateur-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('idFormateur').value = btn.dataset.formateur;
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const cfg = slideConfig[currentSlide];
|
||||
if (!cfg || cfg.next == null) return;
|
||||
// Slide 1 : le nom du groupe est obligatoire pour passer à l'exercice 1
|
||||
if (currentSlide === 1) {
|
||||
const g = document.getElementById('idGroupe');
|
||||
if (!g.value.trim()) {
|
||||
g.style.borderColor = 'var(--coral)';
|
||||
document.getElementById('idWarning').style.display = 'block';
|
||||
g.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
goTo(cfg.next);
|
||||
}
|
||||
|
||||
function showHintCurrent() {
|
||||
const cfg = slideConfig[currentSlide];
|
||||
if (cfg && cfg.hint) showHint(cfg.hint);
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
document.getElementById('progressFill').style.width =
|
||||
currentSlide === 0 ? '0%' : ((currentSlide / totalSlides) * 100) + '%';
|
||||
const counter = document.getElementById('slideCounter');
|
||||
counter.textContent = currentSlide === 0 ? '' : currentSlide + ' / ' + totalSlides;
|
||||
document.getElementById('bottomBar').style.display = currentSlide === 0 ? 'none' : 'flex';
|
||||
document.getElementById('backBtn').style.display = history.length > 0 ? 'block' : 'none';
|
||||
document.getElementById('backBtn').style.display = history.length > 0 ? 'inline-flex' : 'none';
|
||||
|
||||
const badge = document.getElementById('phaseBadge');
|
||||
if (currentSlide >= 2 && currentSlide <= 5) {
|
||||
badge.style.display = 'block';
|
||||
if (currentSlide === 5) {
|
||||
badge.style.background = 'var(--coral-light)';
|
||||
badge.style.color = 'var(--coral)';
|
||||
badge.textContent = 'Cas de synthèse';
|
||||
} else {
|
||||
badge.style.background = 'var(--amber-light)';
|
||||
badge.style.color = 'var(--amber-dark)';
|
||||
badge.textContent = 'Exercice ' + (currentSlide - 1) + ' / 4';
|
||||
}
|
||||
const cfg = slideConfig[currentSlide] || {};
|
||||
const hintBtn = document.getElementById('footerHintBtn');
|
||||
const nextBtn = document.getElementById('footerNextBtn');
|
||||
hintBtn.style.display = cfg.hint ? 'inline-flex' : 'none';
|
||||
if (cfg.next != null) {
|
||||
nextBtn.style.display = 'inline-flex';
|
||||
nextBtn.textContent = cfg.nextLabel;
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
nextBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +775,78 @@ function restartAll() {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function launchConfetti() {
|
||||
const canvas = document.getElementById('confettiCanvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = 'block';
|
||||
|
||||
const colors = ['#BA7517', '#D85A30', '#F5DFB8', '#633806', '#F0A855', '#E8C068', '#FF8C5A'];
|
||||
const particles = Array.from({ length: 180 }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: -(Math.random() * canvas.height * 0.5),
|
||||
w: Math.random() * 10 + 5,
|
||||
h: Math.random() * 5 + 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotSpeed: (Math.random() - 0.5) * 0.18,
|
||||
vx: (Math.random() - 0.5) * 2.5,
|
||||
vy: Math.random() * 3 + 2,
|
||||
opacity: 1,
|
||||
shape: Math.random() < 0.4 ? 'circle' : 'rect'
|
||||
}));
|
||||
|
||||
let rafId;
|
||||
let start = null;
|
||||
const totalDuration = 3800;
|
||||
|
||||
function draw(ts) {
|
||||
if (!start) start = ts;
|
||||
const elapsed = ts - start;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let active = false;
|
||||
|
||||
particles.forEach(p => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.04;
|
||||
p.rotation += p.rotSpeed;
|
||||
p.opacity = elapsed < totalDuration * 0.55
|
||||
? 1
|
||||
: Math.max(0, 1 - (elapsed - totalDuration * 0.55) / (totalDuration * 0.45));
|
||||
|
||||
if (p.y < canvas.height + 20) active = true;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rotation);
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = p.color;
|
||||
if (p.shape === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
if (active && elapsed < totalDuration * 1.3) {
|
||||
rafId = requestAnimationFrame(draw);
|
||||
} else {
|
||||
canvas.style.display = 'none';
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -745,6 +890,116 @@ function exporterPDF() {
|
||||
setTimeout(() => w.print(), 300);
|
||||
}
|
||||
|
||||
/* ======================== SAUVEGARDE AUTOMATIQUE ======================== */
|
||||
/* Les réponses sont enregistrées dans le navigateur au fil de la saisie, puis
|
||||
proposées à la restauration au rechargement. En file://, localStorage peut être
|
||||
indisponible (Safari) : dans ce cas la page fonctionne normalement, sans filet. */
|
||||
const CLE_SAUVEGARDE = 'certibiocide:biocid:v1';
|
||||
|
||||
let reponsesModifiees = false;
|
||||
function marquerModifie() { reponsesModifiees = true; sauvegarder(); }
|
||||
|
||||
document.addEventListener('input', marquerModifie, true);
|
||||
document.addEventListener('change', marquerModifie, true);
|
||||
|
||||
// Avertit avant tout rechargement ou départ. Le texte de la boîte est imposé
|
||||
// par le navigateur, il n'est pas personnalisable.
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!reponsesModifiees) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
|
||||
function etatCourant() {
|
||||
const champs = {};
|
||||
document.querySelectorAll('input[id], textarea[id]').forEach(el => {
|
||||
if (el.value) champs[el.id] = el.value;
|
||||
});
|
||||
return { savedAt: new Date().toISOString(), slide: currentSlide, champs };
|
||||
}
|
||||
|
||||
let minuteurSauvegarde = null;
|
||||
function sauvegarder() {
|
||||
clearTimeout(minuteurSauvegarde);
|
||||
minuteurSauvegarde = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(CLE_SAUVEGARDE, JSON.stringify(etatCourant()));
|
||||
} catch (e) { /* navigation privée ou quota : on continue sans sauvegarde */ }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function lireSauvegarde() {
|
||||
try { return JSON.parse(localStorage.getItem(CLE_SAUVEGARDE)); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function effacerSauvegarde() {
|
||||
try { localStorage.removeItem(CLE_SAUVEGARDE); } catch (e) {}
|
||||
}
|
||||
|
||||
function appliquerSauvegarde(data) {
|
||||
Object.entries(data.champs || {}).forEach(([id, v]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = v;
|
||||
});
|
||||
reponsesModifiees = true;
|
||||
if (typeof data.slide === 'number' && data.slide > 0) goTo(data.slide);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
/* Bandeau de restauration : on ne restaure jamais sans demander, pour qu'un
|
||||
nouveau groupe n'hérite pas des réponses du précédent sur un poste partagé. */
|
||||
function injecterStylesBandeau() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.restore-bar {
|
||||
position: fixed; top: 84px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 900; max-width: min(680px, calc(100vw - 32px));
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
background: var(--white); border: 1.5px solid var(--amber);
|
||||
border-radius: 14px; padding: 14px 18px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12); font-size: 13.5px;
|
||||
}
|
||||
.restore-bar .restore-text { flex: 1; min-width: 220px; line-height: 1.5; }
|
||||
.restore-bar .restore-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.restore-bar button {
|
||||
font-family: 'Syne', sans-serif; font-weight: 700; font-size: 12.5px;
|
||||
padding: 8px 16px; border-radius: 20px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: transparent; color: var(--text-muted);
|
||||
}
|
||||
.restore-bar button:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.restore-bar button.primary {
|
||||
background: var(--amber); border-color: var(--amber); color: #fff;
|
||||
}
|
||||
.restore-bar button.primary:hover { background: var(--amber-dark); border-color: var(--amber-dark); color: #fff; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function proposerRestauration() {
|
||||
const data = lireSauvegarde();
|
||||
if (!data || !data.champs || Object.keys(data.champs).length === 0) return;
|
||||
|
||||
const quand = new Date(data.savedAt);
|
||||
const libelle = isNaN(quand.getTime()) ? '' :
|
||||
` du ${quand.toLocaleDateString('fr-FR')} à ${quand.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
injecterStylesBandeau();
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'restore-bar';
|
||||
bar.innerHTML = `
|
||||
<span class="restore-text">💾 Des réponses enregistrées${libelle} ont été retrouvées sur ce poste.</span>
|
||||
<span class="restore-actions">
|
||||
<button type="button" class="primary" id="btnRestaurer">Restaurer</button>
|
||||
<button type="button" id="btnRepartir">Repartir de zéro</button>
|
||||
</span>`;
|
||||
document.body.appendChild(bar);
|
||||
|
||||
document.getElementById('btnRestaurer').onclick = () => { appliquerSauvegarde(data); bar.remove(); };
|
||||
document.getElementById('btnRepartir').onclick = () => { effacerSauvegarde(); bar.remove(); };
|
||||
}
|
||||
|
||||
proposerRestauration();
|
||||
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding-top: calc(70px + 2rem);
|
||||
padding-bottom: calc(52px + 2rem);
|
||||
padding-bottom: calc(84px + 2rem);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
overflow-y: auto;
|
||||
@@ -46,33 +46,63 @@
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* BOTTOM BAR */
|
||||
/* BOTTOM BAR (modèle BioCID) */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 52px;
|
||||
height: 84px;
|
||||
background: rgba(250, 238, 218, 0.98);
|
||||
border-top: 2px solid var(--amber);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.slide-counter { font-size: 12px; font-family: 'Syne', sans-serif; font-weight: 600; color: var(--amber-dark); }
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 0.5px solid var(--amber);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
.slide-counter {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
font-size: 12px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 600;
|
||||
color: var(--amber-dark);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
.back-btn:hover { background: rgba(186,117,23,0.08); }
|
||||
/* Boutons centralisés dans la bottom bar */
|
||||
.bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.footer-btn {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 11px 24px;
|
||||
border-radius: 40px;
|
||||
border: 1.5px solid var(--amber);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.footer-btn:hover { transform: translateY(-1px); }
|
||||
.footer-btn:active { transform: scale(0.98); }
|
||||
.footer-btn.is-primary { background: var(--amber); color: #fff; }
|
||||
.footer-btn.is-primary:hover { background: var(--amber-dark); }
|
||||
.footer-btn.is-secondary { background: transparent; color: var(--amber-dark); }
|
||||
.footer-btn.is-secondary:hover { background: rgba(186,117,23,0.08); }
|
||||
.footer-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; pointer-events: none; }
|
||||
@media (max-width: 560px) {
|
||||
.slide-counter { display: none; }
|
||||
.footer-btn { padding: 9px 16px; font-size: 12px; }
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.btn {
|
||||
@@ -210,7 +240,7 @@
|
||||
#slide-3 {
|
||||
justify-content: flex-start;
|
||||
padding-top: calc(70px + 0.9rem);
|
||||
padding-bottom: calc(52px + 0.6rem);
|
||||
padding-bottom: calc(84px + 0.6rem);
|
||||
}
|
||||
#slide-3 .slide-title { margin: 0.1rem 0 0.15rem; font-size: clamp(19px, 2.4vw, 26px); }
|
||||
.dnd-hint { font-size: 12.5px; color: var(--text-muted); text-align: center; margin: 0 0 10px; }
|
||||
@@ -229,8 +259,8 @@
|
||||
.dnd-bank { display: grid; grid-template-columns: 1fr; gap: 6px; align-content: start; }
|
||||
.dnd-tile {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px; font-weight: 800;
|
||||
padding: 8px 10px; min-width: 66px; text-align: center;
|
||||
font-size: 12px; font-weight: 800; white-space: nowrap;
|
||||
padding: 8px 12px; min-width: 92px; text-align: center;
|
||||
border-radius: 8px;
|
||||
background: var(--amber-light); color: var(--amber-dark);
|
||||
border: 1px solid var(--amber);
|
||||
@@ -261,7 +291,7 @@
|
||||
.drop-slot.slot-wrong { border-color: #c62828; color: #c62828; }
|
||||
@media (max-width: 900px) {
|
||||
.dnd-layout { grid-template-columns: 1fr; }
|
||||
.dnd-bank { grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); }
|
||||
.dnd-bank { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); }
|
||||
}
|
||||
/* SLIDE 5 — RELIER (MATCHING) */
|
||||
.match-hint { font-size: 12.5px; color: var(--text-muted); text-align: center; margin: -0.4rem 0 0.1rem; max-width: 640px; line-height: 1.5; }
|
||||
@@ -502,6 +532,146 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
.answer-row { flex-wrap: wrap; }
|
||||
select { width: 100%; min-width: 0; }
|
||||
}
|
||||
|
||||
/* ============ SLIDE 7 — ÉTIQUETTE CLIQUABLE (HOTSPOTS) ============ */
|
||||
#slide-7 { justify-content: flex-start; }
|
||||
#slide-7 .scroll-area { gap: 0.75rem; }
|
||||
|
||||
/* Mise en situation */
|
||||
.situation-box {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
max-width: 680px; width: 100%;
|
||||
background: var(--teal-light);
|
||||
border: 1px solid var(--teal-mid);
|
||||
border-left: 4px solid var(--teal-mid);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: var(--teal-dark);
|
||||
font-size: 13.5px; line-height: 1.55;
|
||||
text-align: left;
|
||||
}
|
||||
.situation-box .sit-ico { font-size: 22px; line-height: 1.1; flex-shrink: 0; }
|
||||
.situation-box strong { color: var(--teal-dark); }
|
||||
|
||||
/* Barre de consigne / question courante */
|
||||
.eti-prompt {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
flex-wrap: wrap; justify-content: center;
|
||||
max-width: 680px; width: 100%;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 10px 16px; text-align: center;
|
||||
}
|
||||
.eti-progress {
|
||||
font-family: 'Syne', sans-serif; font-weight: 800; font-size: 11px;
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
color: var(--teal-dark); background: var(--teal-light);
|
||||
padding: 4px 10px; border-radius: 20px; white-space: nowrap;
|
||||
}
|
||||
.eti-question { font-size: 14.5px; font-weight: 600; color: var(--text); }
|
||||
|
||||
/* Scène de l'étiquette + popover */
|
||||
.eti-stage { position: relative; width: 100%; max-width: 680px; display: flex; justify-content: center; }
|
||||
|
||||
/* Étiquette reconstituée (DÉS'INFECT — Annexe I FHER) */
|
||||
.label-card {
|
||||
position: relative;
|
||||
width: 100%; max-width: 640px;
|
||||
background: #fff; color: #1a1a18;
|
||||
border: 1px solid #d9d2c4; border-radius: 6px;
|
||||
padding: 16px 18px 0; overflow: hidden;
|
||||
font-family: 'DM Sans', sans-serif; font-size: 10.5px; line-height: 1.42;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.10);
|
||||
}
|
||||
.lbl-top { display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #1a1a18; padding-bottom: 6px; margin-bottom: 10px; }
|
||||
.lbl-flag { font-size: 20px; }
|
||||
.lbl-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: clamp(24px, 5vw, 40px); letter-spacing: -0.01em; margin: 0; }
|
||||
.lbl-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.lbl-h { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.02em; margin-bottom: 2px; }
|
||||
.lbl-block { margin-bottom: 9px; }
|
||||
.lbl-small { color: #555; font-size: 9.5px; line-height: 1.6; margin-top: 4px; }
|
||||
.lbl-bottom { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 14px; margin-top: 4px; padding: 10px 0; }
|
||||
.lbl-net { display: flex; flex-direction: column; gap: 2px; }
|
||||
.lbl-net span { font-size: 10px; }
|
||||
.lbl-net strong { font-family: 'Syne', sans-serif; font-size: 22px; line-height: 1; }
|
||||
.lbl-barcode {
|
||||
height: 42px; align-self: end; min-width: 90px;
|
||||
background: repeating-linear-gradient(90deg, #1a1a18 0 2px, #fff 2px 4px, #1a1a18 4px 5px, #fff 5px 9px);
|
||||
}
|
||||
.lbl-picto-wrap { display: flex; align-items: center; gap: 8px; }
|
||||
.pictos { display: flex; gap: 6px; align-items: center; }
|
||||
.ghs-img { width: 42px; height: 42px; object-fit: contain; display: block; }
|
||||
.ghs {
|
||||
width: 38px; height: 38px; position: relative;
|
||||
border: 2.5px solid #c62828; background: #fff;
|
||||
transform: rotate(45deg);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.ghs span { transform: rotate(-45deg); font-size: 17px; line-height: 1; }
|
||||
.ghs.excl span { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 24px; color: #1a1a18; }
|
||||
.lbl-danger { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 15px; color: #1a1a18; letter-spacing: 0.02em; }
|
||||
.lbl-footer { margin: 6px -18px 0; padding: 8px 18px; background: #2b2b28; color: #e8e2d5; text-align: center; font-size: 9px; line-height: 1.5; }
|
||||
|
||||
/* Zones cliquables (hotspots) */
|
||||
.hotspot {
|
||||
position: relative; cursor: pointer; border-radius: 5px;
|
||||
outline: 1.5px dashed transparent; outline-offset: 1px;
|
||||
transition: background 0.15s ease, outline-color 0.15s ease;
|
||||
}
|
||||
span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
|
||||
.label-card:not(.recap) .hotspot:hover { background: rgba(43,122,120,0.10); outline-color: var(--teal-mid); }
|
||||
.hotspot.hs-correct { background: rgba(46,125,50,0.14); outline: 1.5px solid #2e7d32; }
|
||||
.hotspot.hs-wrong { background: rgba(198,40,40,0.16); outline: 1.5px solid #c62828; animation: hsShake 0.4s ease; }
|
||||
@keyframes hsShake { 0%,100%{transform:translateX(0);} 25%{transform:translateX(-3px);} 75%{transform:translateX(3px);} }
|
||||
.hs-num {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #2e7d32; color: #fff;
|
||||
font-family: 'Syne', sans-serif; font-weight: 800; font-size: 9px;
|
||||
margin-right: 3px; vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Info-bulle réglementaire */
|
||||
.eti-pop {
|
||||
position: absolute; z-index: 20; max-width: 290px; width: max-content;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
|
||||
padding: 12px 30px 12px 14px; font-size: 12.5px; line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
.eti-pop[hidden] { display: none; }
|
||||
.eti-pop .pop-status { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
|
||||
.eti-pop.ok .pop-status { color: #2e7d32; }
|
||||
.eti-pop.ko .pop-status { color: #c62828; }
|
||||
.eti-pop.info .pop-status { color: var(--teal-dark); }
|
||||
.eti-pop .pop-title { font-weight: 700; color: var(--text); margin-bottom: 3px; }
|
||||
.eti-pop .pop-reg { color: var(--text-muted); }
|
||||
.eti-pop .pop-close { position: absolute; top: 5px; right: 8px; cursor: pointer; color: var(--text-muted); font-size: 16px; line-height: 1; border: none; background: none; padding: 2px; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.lbl-cols { grid-template-columns: 1fr; }
|
||||
.lbl-title { font-size: 26px; }
|
||||
}
|
||||
|
||||
/* ---- SLIDE 1 — sélection formateur + garde nom ---- */
|
||||
.formateur-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); margin: 4px 0 2px; }
|
||||
.formateur-row { display: flex; gap: 8px; }
|
||||
.formateur-btn {
|
||||
flex: 1; font-family: 'DM Sans', sans-serif; font-size: 13.5px;
|
||||
padding: 9px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg); color: var(--text); cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.formateur-btn:hover { border-color: var(--amber); }
|
||||
.formateur-btn.active { background: var(--amber); border-color: var(--amber); color: #fff; }
|
||||
.identity-error { color: var(--coral); font-size: 12px; margin-top: 8px; line-height: 1.4; }
|
||||
.identity-error[hidden] { display: none; }
|
||||
|
||||
/* ---- SLIDE 7 — encadré de réponse sur le côté de l'étiquette ---- */
|
||||
#eti-stage {
|
||||
max-width: 680px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -522,19 +692,19 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
|
||||
<!-- Phase badge -->
|
||||
<div class="phase-badge" id="phaseBadge" style="display:none"></div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="bottom-bar" id="bottomBar" style="display:none;">
|
||||
<button class="back-btn" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
|
||||
<div class="slide-counter" id="slideCounter"></div>
|
||||
<div class="bar-actions" id="barActions"></div>
|
||||
</div>
|
||||
|
||||
<!-- Confetti -->
|
||||
<canvas id="confettiCanvas" style="position:fixed;inset:0;pointer-events:none;z-index:150;display:none;"></canvas>
|
||||
|
||||
<!-- ============ SLIDE 0 — ACCUEIL ============ -->
|
||||
<div class="slide active fade-in" id="slide-0">
|
||||
<div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div>
|
||||
<div class="deco-circle" style="width:200px;height:200px;background:var(--coral-light);bottom:60px;left:-60px;opacity:0.5;"></div>
|
||||
<div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div>
|
||||
<div style="text-align:center;">
|
||||
<div class="hero-label">Certibiocide — Travaux dirigés</div>
|
||||
<div class="hero-title">FDS, FT<br>& Étiquettes</div>
|
||||
@@ -549,6 +719,10 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<div class="section-tag">Déroulé du TD</div>
|
||||
<div class="slide-title">Trois documents, trois usages</div>
|
||||
<p class="slide-intro">Vous allez apprendre à localiser une information dans chacun des documents de référence d'un produit biocide.</p>
|
||||
<div class="info-card">
|
||||
<span>⏱️</span>
|
||||
<div>Durée ≈ 30 min. Pour chaque question, sélectionnez la <strong>zone du document</strong> où trouver l'information, puis vérifiez votre score.</div>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<div class="overview-card">
|
||||
<div class="overview-icon">📄</div>
|
||||
@@ -566,19 +740,20 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<div class="overview-sub">Interface directe avec l'utilisateur</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<span>⏱️</span>
|
||||
<div>Durée ≈ 30 min. Pour chaque question, sélectionnez la <strong>zone du document</strong> où trouver l'information, puis vérifiez votre score.</div>
|
||||
</div>
|
||||
<div class="identity-card">
|
||||
<div class="identity-title">👥 Identification du groupe</div>
|
||||
<div class="identity-fields">
|
||||
<input type="text" id="idGroupe" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off">
|
||||
<input type="text" id="idGroupe" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off" oninput="clearGroupeError()">
|
||||
<input type="date" id="idDate">
|
||||
<input type="text" id="idFormateur" placeholder="Formateur (facultatif)" autocomplete="off">
|
||||
<input type="hidden" id="idFormateur">
|
||||
<div class="formateur-label">Formateur de la session</div>
|
||||
<div class="formateur-row">
|
||||
<button type="button" class="formateur-btn" data-formateur="Nathan" onclick="selectFormateur(this)">Nathan</button>
|
||||
<button type="button" class="formateur-btn" data-formateur="Gauthier" onclick="selectFormateur(this)">Gauthier</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="identity-error" id="idGroupe-error" hidden>⚠️ Renseignez le nom du stagiaire ou du groupe pour continuer.</div>
|
||||
</div>
|
||||
<button class="btn btn-amber" onclick="goTo(2)" style="margin-top:1.5rem;">Commencer la partie 1 →</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 2 — FDS INTRO ============ -->
|
||||
@@ -591,7 +766,6 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<span>🎯</span>
|
||||
<div>Pour chaque question, indiquez dans quelle <strong>rubrique (1 à 16)</strong> trouver la réponse — ou « Absent » si l'information n'y figure pas. Les questions sont mélangées à chaque chargement.</div>
|
||||
</div>
|
||||
<button class="btn btn-amber" onclick="goTo(3)" style="margin-top:1.5rem;">Répondre aux questions →</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 3 — FDS QUIZ (DRAG & DROP) ============ -->
|
||||
@@ -603,12 +777,7 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<div class="quiz-list quiz-grid" id="fds-questions"></div>
|
||||
<div class="dnd-bank" id="fds-bank-right"></div>
|
||||
</div>
|
||||
<button class="verify-btn btn-amber" id="fds-verify" onclick="verifyFDS()" disabled>✓ Vérifier mes réponses</button>
|
||||
<div id="fds-result" class="result-message"></div>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-outline" onclick="resetFDS()">↺ Recommencer</button>
|
||||
<button class="btn btn-coral" onclick="goTo(4)">Passer à la partie 2 →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 4 — FT INTRO ============ -->
|
||||
@@ -621,13 +790,11 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<span>🎯</span>
|
||||
<div>Indiquez la <strong>section</strong> de la FT où trouver chaque information : Description, Propriétés biocides, Conditionnements, Utilisations — ou « Absent ».</div>
|
||||
</div>
|
||||
<button class="btn btn-coral" onclick="goTo(5)" style="margin-top:1.5rem;">Répondre aux questions →</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 5 — FT : RELIER ============ -->
|
||||
<div class="slide" id="slide-5">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag" style="background:var(--coral-light);color:var(--coral);">Partie 2 / 3 — FT</div>
|
||||
<div class="slide-title">Sections de la fiche technique</div>
|
||||
<p class="match-hint">Reliez chaque question à la bonne section : cliquez sur un point à gauche, maintenez, puis glissez jusqu'au point de la section correspondante à droite.</p>
|
||||
<div class="match-wrap" id="ft-match">
|
||||
@@ -635,12 +802,7 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<div class="match-col match-left" id="ft-left"></div>
|
||||
<div class="match-col match-right" id="ft-right"></div>
|
||||
</div>
|
||||
<button class="verify-btn btn-coral" id="ft-verify" onclick="verifyFT()" disabled>✓ Vérifier mes réponses</button>
|
||||
<div id="ft-result" class="result-message"></div>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-outline" onclick="resetFT()">↺ Recommencer</button>
|
||||
<button class="btn btn-teal" onclick="goTo(6)">Passer à la partie 3 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -652,22 +814,89 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<p class="slide-intro">L'étiquette est l'interface directe entre le produit et l'utilisateur. Elle communique rapidement les <strong>dangers essentiels</strong>, les <strong>EPI</strong> et les <strong>consignes d'utilisation</strong>.</p>
|
||||
<div class="info-card" style="background:var(--teal-light);color:var(--teal-dark);">
|
||||
<span>🎯</span>
|
||||
<div>Indiquez la <strong>zone de l'étiquette</strong> où trouver l'information : Identité, Pictogrammes, Mots d'avertissement, Mentions de danger, Conseils de prudence, Contenu net, Numéro d'urgence — ou « Absent ».</div>
|
||||
<div>Sur une <strong>étiquette réelle</strong> (produit professionnel DÉS'INFECT), <strong>cliquez la zone</strong> qui répond à chaque question : nom commercial, pictogrammes, mention d'avertissement, phrases H, phrases P (EPI, premiers secours, élimination), contenu net et centre antipoison. La <strong>justification réglementaire</strong> de chaque zone est fournie sur la page de correction de l'exercice.</div>
|
||||
</div>
|
||||
<button class="btn btn-teal" onclick="goTo(7)" style="margin-top:1.5rem;">Répondre aux questions →</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 7 — ÉTIQUETTE QUIZ ============ -->
|
||||
<!-- ============ SLIDE 7 — ÉTIQUETTE CLIQUABLE (HOTSPOTS) ============ -->
|
||||
<div class="slide" id="slide-7">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag" style="background:var(--teal-light);color:var(--teal-dark);">Partie 3 / 3 — Étiquette</div>
|
||||
<div class="slide-title">Zones de l'étiquette</div>
|
||||
<div class="quiz-list" id="etiquette-questions"></div>
|
||||
<button class="verify-btn btn-teal" id="eti-verify" onclick="verifyEtiquette()" disabled>✓ Vérifier mes réponses</button>
|
||||
<div id="etiquette-result" class="result-message"></div>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="goTo(8)">Voir la synthèse →</button>
|
||||
|
||||
<!-- ---- PANNEAU A : exercice ---- -->
|
||||
<div id="eti-exercise" style="width:100%;display:flex;flex-direction:column;align-items:center;gap:0.75rem;">
|
||||
<div class="slide-title">Zones de l'étiquette</div>
|
||||
|
||||
<div class="situation-box">
|
||||
<span class="sit-ico">💬</span>
|
||||
<div><strong>Mise en situation —</strong> Vous intervenez chez un client avec un désinfectant de surfaces <strong>à usage exclusivement professionnel</strong>. Avant l'application, il vous interroge sur les dangers et les précautions du produit. Bidon en main, retrouvez rapidement sur l'étiquette chaque information demandée.</div>
|
||||
</div>
|
||||
|
||||
<div class="eti-prompt">
|
||||
<span class="eti-progress" id="eti-progress">Question 1 / 9</span>
|
||||
<span class="eti-question" id="eti-question">—</span>
|
||||
</div>
|
||||
|
||||
<div class="eti-stage" id="eti-stage">
|
||||
<div class="label-card" id="labelCard">
|
||||
<!-- Titre / nom commercial -->
|
||||
<div class="lbl-top">
|
||||
<span class="lbl-flag">🇫🇷</span>
|
||||
<span class="hotspot" data-zone="z-nom"><span class="lbl-title">DÉS'INFECT</span></span>
|
||||
</div>
|
||||
|
||||
<div class="lbl-cols">
|
||||
<!-- Colonne gauche (contexte, non cliquable) -->
|
||||
<div class="lbl-col">
|
||||
<div class="lbl-block"><div class="lbl-h">Propriétés :</div>Désinfecte et désodorise les sanitaires. Produit strictement destiné à un usage professionnel.</div>
|
||||
<div class="lbl-block"><div class="lbl-h">Mode d'emploi :</div>Concentré à diluer : 5 % pour une activité fongicide, 1 % pour une activité bactéricide. Pulvériser sur les parois, laisser agir 5 à 15 min, rincer à l'eau claire. Type de formulation : concentré soluble (SL). Utilisable en TP2.</div>
|
||||
<div class="lbl-small">UFI : 1234-1234-1234-1234<br>Date de péremption : MM/AAAA<br>N° LOT : 123456</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite (précautions, cliquable) -->
|
||||
<div class="lbl-col">
|
||||
<div class="lbl-block"><div class="lbl-h">Substances actives :</div>Chlorure de didécyldiméthylammonium (N°CAS 7173-51-5) à 75 g/kg ; acide glycolique (CAS 79-14-1) à 35 g/kg.</div>
|
||||
<div class="lbl-block">
|
||||
<div class="lbl-h">Précautions d'emploi :</div>
|
||||
<span>Produit strictement professionnel (FR) — Lire le mode d'emploi ci-joint avant l'emploi. </span>
|
||||
<span class="hotspot" data-zone="z-antipoison">Formule déposée au centre anti-poison de Nancy : 03 83 22 50 50.</span>
|
||||
<span> </span>
|
||||
<span class="hotspot" data-zone="z-h">H302 Nocif en cas d'ingestion. H314 Provoque de graves brûlures de la peau et des lésions oculaires. H410 Très toxique pour les organismes aquatiques, entraîne des effets néfastes à long terme.</span>
|
||||
<span> </span>
|
||||
<span class="hotspot" data-zone="z-p-epi">P280 Porter des gants de protection, des vêtements de protection et un équipement de protection du visage.</span>
|
||||
<span> </span>
|
||||
<span class="hotspot" data-zone="z-p-secours">P301+P330+P331 EN CAS D'INGESTION : rincer la bouche, ne pas faire vomir. P305+P351+P338 EN CAS DE CONTACT AVEC LES YEUX : rincer avec précaution à l'eau pendant plusieurs minutes. P310 Appeler immédiatement un CENTRE ANTIPOISON ou un médecin.</span>
|
||||
<span> </span>
|
||||
<span class="hotspot" data-zone="z-p-elim">P501 Éliminer le contenu / le récipient dans un point de collecte des déchets spéciaux ou dangereux.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bas : contenu net, code-barres, pictogrammes, mention -->
|
||||
<div class="lbl-bottom">
|
||||
<span class="hotspot" data-zone="z-net">
|
||||
<span class="lbl-net"><span>Volume net :</span><strong>5 L</strong></span>
|
||||
</span>
|
||||
<div class="lbl-barcode" aria-hidden="true"></div>
|
||||
<div class="lbl-picto-wrap">
|
||||
<span class="hotspot" data-zone="z-picto">
|
||||
<span class="pictos">
|
||||
<img class="ghs-img" src="../data/ghs09-environnement.jpg" alt="Pictogramme SGH09 — danger pour le milieu aquatique">
|
||||
<img class="ghs-img" src="../data/ghs05-corrosif.jpg" alt="Pictogramme SGH05 — corrosif">
|
||||
<img class="ghs-img" src="../data/ghs07-nocif.jpg" alt="Pictogramme SGH07 — nocif / irritant">
|
||||
</span>
|
||||
</span>
|
||||
<span class="hotspot" data-zone="z-mention"><span class="lbl-danger">DANGER</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lbl-footer">FHER — 2, rue de Sèze, 75009 Paris — Tél. 01 23 45 67 89</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="etiquette-result" class="result-message"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -690,19 +919,10 @@ select:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px
|
||||
<tr><td><strong>Étiquette</strong></td><td>Dangers essentiels, interface rapide</td><td>À chaque prise du produit</td><td>Tous les utilisateurs</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul class="tips-list">
|
||||
<li><strong>FDS = Sécurité et dangers</strong> → à consulter AVANT manipulation.</li>
|
||||
<li><strong>FT = Mode d'emploi et applications</strong> → à consulter pour l'utilisation.</li>
|
||||
<li><strong>Étiquette = Interface rapide</strong> → lecture obligatoire avant ouverture du produit.</li>
|
||||
<li>Les trois documents sont <strong>complémentaires</strong>, jamais redondants — chacun cible un besoin et un public différents.</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
|
||||
<a href="correction-fds.html" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
</div>
|
||||
<div class="actions-row" style="margin-top:0.5rem;">
|
||||
<a href="corriger-exercice.html?exo=fds" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
<button class="btn btn-outline" onclick="restartAll()">↺ Recommencer</button>
|
||||
<a href="../index.html#formations" class="btn btn-outline">Retour aux exercices</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -735,25 +955,47 @@ const fdsQuestions = [
|
||||
const ftQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
|
||||
{ id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Absent" },
|
||||
{ id: 3, text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
|
||||
{ id: 3, text: "Où se trouve la liste des TP (type de produit) ?", answer: "Propriétés biocides" },
|
||||
{ id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
|
||||
{ id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" }
|
||||
];
|
||||
const ftOptions = ["Description", "Propriétés biocides", "Conditionnements", "Utilisations"];
|
||||
|
||||
const etiquetteQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
|
||||
{ id: 2, text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
|
||||
{ id: 3, text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
|
||||
{ id: 4, text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
|
||||
{ id: 5, text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
|
||||
{ id: 6, text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
|
||||
{ id: 7, text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
|
||||
{ id: 8, text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
|
||||
{ id: 9, text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
|
||||
{ id: 10, text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" }
|
||||
/* 9 zones réglementaires de l'étiquette (ordre canonique = check-list FHER p.15).
|
||||
zone : identifiant du hotspot correspondant dans l'étiquette
|
||||
text : consigne affichée pendant l'exercice
|
||||
label : intitulé check-list (réutilisé dans la synthèse et le PDF)
|
||||
reg : justification réglementaire (info-bulle) — Guide FHER, Partie 3, p.10-12 */
|
||||
const etiZones = [
|
||||
{ zone: "z-nom", text: "Retrouvez le nom commercial du produit.",
|
||||
label: "Nom commercial",
|
||||
reg: "Obligation CLP : le nom commercial doit être identique à celui figurant sur la FDS (Guide FHER, Partie 3 — I.2)." },
|
||||
{ zone: "z-picto", text: "Cliquez sur les pictogrammes de danger.",
|
||||
label: "Pictogrammes de danger",
|
||||
reg: "CLP : pictogrammes aux dimensions requises, regroupés avec la mention d'avertissement et les phrases H/P, se détachant nettement du fond (Partie 3 — I.5)." },
|
||||
{ zone: "z-mention", text: "Où figure la mention d'avertissement ?",
|
||||
label: "Mention d'avertissement",
|
||||
reg: "CLP : « Danger » ou « Attention », disposée dans l'encart d'étiquetage avec pictogrammes et phrases H/P (Partie 3 — I.5)." },
|
||||
{ zone: "z-h", text: "Repérez les mentions de danger (phrases H).",
|
||||
label: "Mentions de danger (phrases H)",
|
||||
reg: "CLP : les phrases H décrivent la nature du danger ; regroupées dans l'encart d'étiquetage (Partie 3 — I.5)." },
|
||||
{ zone: "z-p-epi", text: "Où sont indiqués les EPI à porter (phrases P) ?",
|
||||
label: "Conseils de prudence — EPI",
|
||||
reg: "CLP : conseils de prudence P (ici P280). Six conseils P au maximum, sauf si la nature et la gravité des dangers l'exigent (CLP Art. 28-3) — Partie 3, I.5." },
|
||||
{ zone: "z-p-secours", text: "Où trouver les instructions de premiers secours ?",
|
||||
label: "Premiers secours (phrases P)",
|
||||
reg: "BPR : indications relatives aux premiers soins (Partie 3 — II.8), traduites par les conseils P 3xx (contact, ingestion, inhalation)." },
|
||||
{ zone: "z-p-elim", text: "Où sont les consignes d'élimination de l'emballage ?",
|
||||
label: "Élimination du produit et de l'emballage",
|
||||
reg: "BPR : instructions pour l'élimination en toute sécurité du produit et de son emballage, le cas échéant avec interdiction de le réutiliser (Partie 3 — II.10). Ici P501." },
|
||||
{ zone: "z-net", text: "Retrouvez le contenu net du produit.",
|
||||
label: "Contenu net",
|
||||
reg: "Quantité nette de produit contenue dans l'emballage — mention générale du pavé d'information produit." },
|
||||
{ zone: "z-antipoison", text: "Où figurent les coordonnées du centre antipoison ?",
|
||||
label: "Centre antipoison / n° d'urgence",
|
||||
reg: "Coordonnées d'urgence complétant les instructions de premiers soins (Partie 3 — II.8) ; les mélanges dangereux sont déclarés aux centres antipoison (annexe VIII du CLP)." }
|
||||
];
|
||||
const etiquetteOptions = ["Identité", "Pictogrammes", "Mots d'avertissement", "Mentions de danger", "Conseils de prudence", "Contenu net", "Numéro d'urgence"];
|
||||
function etiZoneById(id) { return etiZones.find(z => z.zone === id); }
|
||||
|
||||
function shuffleArray(array) {
|
||||
const a = [...array];
|
||||
@@ -764,17 +1006,6 @@ function shuffleArray(array) {
|
||||
return a;
|
||||
}
|
||||
|
||||
function buildRow(qNum, selectId, answerAttr, text, options, badgeStyle) {
|
||||
let opts = '<option value="">-- Sélectionner --</option>';
|
||||
options.forEach(o => { opts += `<option value="${o.value}">${o.label}</option>`; });
|
||||
return `
|
||||
<div class="answer-row">
|
||||
<div class="question-num" style="${badgeStyle}">Q${qNum}</div>
|
||||
<div class="question-text">${text}</div>
|
||||
<select id="${selectId}" data-answer="${answerAttr}">${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateQuizzes() {
|
||||
// FDS — glisser-déposer : banques d'étiquettes + cases de dépôt
|
||||
buildFdsDnd();
|
||||
@@ -782,19 +1013,106 @@ function generateQuizzes() {
|
||||
// FT — exercice « relier » (questions à gauche, sections à droite)
|
||||
buildFtMatch();
|
||||
|
||||
// Étiquette
|
||||
const etiOpts = etiquetteOptions.map(o => ({ value: o, label: o }));
|
||||
etiOpts.push({ value: 'absent', label: 'Absent' });
|
||||
const etiC = document.getElementById('etiquette-questions');
|
||||
etiC.innerHTML = '';
|
||||
etiquetteQuestions.forEach((q, i) => {
|
||||
etiC.innerHTML += buildRow(i + 1, 'eti' + q.id, q.answer, q.text, etiOpts, 'background:var(--teal-light);color:var(--teal-dark);');
|
||||
});
|
||||
|
||||
// Bloque la vérification tant que toutes les questions ne sont pas répondues
|
||||
wireVerifyGate('etiquette-questions', 'eti-verify');
|
||||
// Étiquette — exercice à zones cliquables (hotspots)
|
||||
buildEtiquette();
|
||||
}
|
||||
|
||||
/* ---- Étiquette : zones cliquables ---- */
|
||||
let etiOrder = []; // ordre aléatoire des questions
|
||||
let etiPos = 0; // question courante
|
||||
let etiAnswers = {}; // { zoneCible : zoneCliquée en 1re tentative } — pour le PDF
|
||||
let etiFirstTry = {}; // { zoneCible : true si trouvée sans erreur }
|
||||
let etiInit = false; // écouteur de clic posé une seule fois
|
||||
|
||||
function buildEtiquette() {
|
||||
etiOrder = shuffleArray(etiZones.map((_, i) => i));
|
||||
etiPos = 0;
|
||||
etiAnswers = {};
|
||||
etiFirstTry = {};
|
||||
|
||||
const card = document.getElementById('labelCard');
|
||||
card.querySelectorAll('.hotspot').forEach(h => h.classList.remove('hs-correct', 'hs-wrong'));
|
||||
|
||||
closeEtiPop();
|
||||
const r = document.getElementById('etiquette-result');
|
||||
r.className = 'result-message'; r.innerHTML = '';
|
||||
if (currentSlide === 7) renderBarActions();
|
||||
|
||||
if (!etiInit) {
|
||||
card.addEventListener('click', onEtiClick);
|
||||
etiInit = true;
|
||||
}
|
||||
renderEtiQuestion();
|
||||
}
|
||||
|
||||
function renderEtiQuestion() {
|
||||
const prog = document.getElementById('eti-progress');
|
||||
const ques = document.getElementById('eti-question');
|
||||
if (etiPos >= etiOrder.length) {
|
||||
prog.textContent = 'Terminé';
|
||||
ques.textContent = '✓ Les 9 zones ont été identifiées.';
|
||||
return;
|
||||
}
|
||||
const z = etiZones[etiOrder[etiPos]];
|
||||
prog.textContent = 'Question ' + (etiPos + 1) + ' / ' + etiZones.length;
|
||||
ques.textContent = z.text;
|
||||
}
|
||||
|
||||
function onEtiClick(e) {
|
||||
const hs = e.target.closest('.hotspot');
|
||||
if (!hs) return;
|
||||
const clicked = hs.dataset.zone;
|
||||
|
||||
// Exercice terminé : plus d'interaction (la correction est sur la page de correction).
|
||||
if (etiPos >= etiOrder.length) return;
|
||||
|
||||
marquerModifie();
|
||||
const target = etiZones[etiOrder[etiPos]];
|
||||
|
||||
// La zone cliquée — bonne ou mauvaise — est enregistrée comme réponse, puis on passe
|
||||
// à la question suivante. Aucun retour de justesse n'est affiché ici : la correction
|
||||
// (étiquette annotée + check-list) est présentée sur la page de correction de l'exercice.
|
||||
etiAnswers[target.zone] = clicked;
|
||||
etiFirstTry[target.zone] = (clicked === target.zone);
|
||||
etiPos++;
|
||||
renderEtiQuestion();
|
||||
if (etiPos >= etiOrder.length) finishEtiquette();
|
||||
}
|
||||
|
||||
function finishEtiquette() {
|
||||
const b = document.getElementById('eti-recap-btn');
|
||||
if (b) b.disabled = false;
|
||||
}
|
||||
|
||||
/* Info-bulle réglementaire positionnée près de la zone cliquée */
|
||||
function openEtiPop(hs, kind, clickedZone) {
|
||||
const pop = document.getElementById('eti-pop');
|
||||
const z = etiZoneById(clickedZone);
|
||||
|
||||
const statusEl = document.getElementById('pop-status');
|
||||
pop.className = 'eti-pop ' + kind;
|
||||
if (kind === 'ok') statusEl.innerHTML = '✅ Bonne zone';
|
||||
else if (kind === 'ko') statusEl.innerHTML = '❌ Ce n\'est pas la bonne zone';
|
||||
else statusEl.innerHTML = '🔎 ' + z.label;
|
||||
|
||||
document.getElementById('pop-title').textContent = z.label;
|
||||
document.getElementById('pop-reg').textContent = z.reg;
|
||||
|
||||
// L'encadré s'affiche dans la colonne latérale, à côté de l'étiquette (plus de superposition).
|
||||
const hint = document.getElementById('eti-side-hint');
|
||||
if (hint) hint.hidden = true;
|
||||
pop.hidden = false;
|
||||
}
|
||||
|
||||
function closeEtiPop() {
|
||||
const pop = document.getElementById('eti-pop');
|
||||
if (pop) pop.hidden = true;
|
||||
const hint = document.getElementById('eti-side-hint');
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
|
||||
function resetEtiquette() { buildEtiquette(); }
|
||||
|
||||
/* ---- FT : exercice « relier » ---- */
|
||||
const ftConnections = {}; // { qId: valeur de la section reliée }
|
||||
let ftDrag = null; // { qid } pendant un glissement
|
||||
@@ -832,7 +1150,7 @@ function buildFtMatch() {
|
||||
|
||||
initFtMatch();
|
||||
document.getElementById('ft-svg').innerHTML = '';
|
||||
document.getElementById('ft-verify').disabled = true;
|
||||
refreshFtState();
|
||||
}
|
||||
|
||||
function resetFT() {
|
||||
@@ -890,7 +1208,8 @@ function refreshFtState() {
|
||||
dot.closest('.match-item').classList.toggle('connected', used);
|
||||
});
|
||||
const all = ftQuestions.every(q => ftConnections[q.id] != null);
|
||||
document.getElementById('ft-verify').disabled = !all;
|
||||
const btn = document.getElementById('ft-next');
|
||||
if (btn) btn.disabled = !all;
|
||||
}
|
||||
|
||||
function initFtMatch() {
|
||||
@@ -919,7 +1238,7 @@ document.addEventListener('pointerup', e => {
|
||||
if (!ftDrag) return;
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const answerDot = target && target.closest('.match-dot[data-side="right"]');
|
||||
if (answerDot) ftConnections[ftDrag.qid] = answerDot.dataset.answer;
|
||||
if (answerDot) { marquerModifie(); ftConnections[ftDrag.qid] = answerDot.dataset.answer; }
|
||||
document.querySelectorAll('.match-dot.active').forEach(d => d.classList.remove('active'));
|
||||
ftDrag = null;
|
||||
refreshFtState();
|
||||
@@ -931,8 +1250,8 @@ window.addEventListener('resize', () => { if (currentSlide === 5) drawFtLines();
|
||||
function buildFdsDnd() {
|
||||
const tile = (v, l) => `<div class="dnd-tile" draggable="true" data-value="${v}" data-label="${l}">${l}</div>`;
|
||||
let left = '', right = '';
|
||||
for (let i = 1; i <= 8; i++) left += tile(String(i), 'R' + i);
|
||||
for (let i = 9; i <= 16; i++) right += tile(String(i), 'R' + i);
|
||||
for (let i = 1; i <= 8; i++) left += tile(String(i), 'Rubrique ' + i);
|
||||
for (let i = 9; i <= 16; i++) right += tile(String(i), 'Rubrique ' + i);
|
||||
right += tile('absent', 'Absent');
|
||||
document.getElementById('fds-bank-left').innerHTML = left;
|
||||
document.getElementById('fds-bank-right').innerHTML = right;
|
||||
@@ -970,6 +1289,7 @@ function initFdsDnd() {
|
||||
r.className = 'result-message'; r.innerHTML = '';
|
||||
};
|
||||
const fill = (slot, v, l) => {
|
||||
marquerModifie();
|
||||
slot.dataset.value = v; slot.textContent = l; slot.classList.add('filled');
|
||||
resetResults(); updateFdsGate();
|
||||
};
|
||||
@@ -1010,7 +1330,8 @@ function initFdsDnd() {
|
||||
function updateFdsGate() {
|
||||
const slots = document.querySelectorAll('#slide-3 .drop-slot');
|
||||
const all = Array.from(slots).every(s => (s.dataset.value || '') !== '');
|
||||
document.getElementById('fds-verify').disabled = !all;
|
||||
const btn = document.getElementById('fds-next');
|
||||
if (btn) btn.disabled = !all;
|
||||
}
|
||||
|
||||
function wireVerifyGate(containerId, btnId) {
|
||||
@@ -1024,22 +1345,6 @@ function wireVerifyGate(containerId, btnId) {
|
||||
}
|
||||
|
||||
/* ======================== VÉRIFICATION ======================== */
|
||||
function showInlineResult(selectEl, isCorrect, isUnanswered, correctAnswerText) {
|
||||
const row = selectEl.closest('.answer-row');
|
||||
row.querySelector('.answer-label')?.remove();
|
||||
row.querySelector('.answer-icon')?.remove();
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'answer-label';
|
||||
label.textContent = correctAnswerText;
|
||||
selectEl.before(label);
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'answer-icon';
|
||||
icon.textContent = isUnanswered ? '❓' : (isCorrect ? '✅' : '❌');
|
||||
selectEl.before(icon);
|
||||
}
|
||||
|
||||
function showScore(resultDiv, correct, total) {
|
||||
const p = Math.round((correct / total) * 100);
|
||||
if (p === 100) {
|
||||
@@ -1102,18 +1407,6 @@ function verifyFT() {
|
||||
showScore(document.getElementById('ft-result'), correct, ftQuestions.length);
|
||||
}
|
||||
|
||||
function verifyEtiquette() {
|
||||
let correct = 0;
|
||||
etiquetteQuestions.forEach(q => {
|
||||
const select = document.getElementById('eti' + q.id);
|
||||
const userAnswer = select.value;
|
||||
const isCorrect = userAnswer === q.answer;
|
||||
if (isCorrect) correct++;
|
||||
showInlineResult(select, isCorrect, userAnswer === '', q.answer);
|
||||
});
|
||||
showScore(document.getElementById('etiquette-result'), correct, etiquetteQuestions.length);
|
||||
}
|
||||
|
||||
/* ======================== EXPORT PDF ======================== */
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
@@ -1168,7 +1461,20 @@ function exporterPDF() {
|
||||
|
||||
const p1 = partHtml('P1', 'Partie 1 — FDS (Fiche de Données de Sécurité)', fdsQuestions, 'fds', fdsAnswerLabel, fdsExpectedLabel, isFdsCorrect);
|
||||
const p2 = partHtml('P2', 'Partie 2 — FT (Fiche Technique)', ftQuestions, 'ft', textAnswerLabel, a => a, isTextCorrect, q => ftConnections[q.id] || '');
|
||||
const p3 = partHtml('P3', 'Partie 3 — Étiquette', etiquetteQuestions, 'eti', textAnswerLabel, a => a, isTextCorrect);
|
||||
|
||||
// Partie 3 — Étiquette (zones cliquables) : ordre check-list
|
||||
const p3rows = etiZones.map((z, i) => {
|
||||
const given = etiAnswers[z.zone];
|
||||
const ok = given === z.zone;
|
||||
nbTotal++; if (ok) scoreTotal++;
|
||||
const givenLabel = given === undefined ? '(non répondu)' : (etiZoneById(given) ? etiZoneById(given).label : given);
|
||||
const mark = given === undefined ? '❓' : (ok ? '✓' : '✗');
|
||||
return `<div style="margin-bottom:10px;">
|
||||
<div><strong>[P3-Q${i + 1}]</strong> ${escapeHtml(z.text)}</div>
|
||||
<div style="margin-top:2px;">Zone désignée : <strong>${escapeHtml(givenLabel)}</strong> — Attendu : ${escapeHtml(z.label)} — ${mark}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const p3 = `<h2>Partie 3 — Étiquette (zones réglementaires)</h2>${p3rows}`;
|
||||
|
||||
const w = window.open('', '_blank');
|
||||
w.document.write(`
|
||||
@@ -1191,6 +1497,28 @@ function exporterPDF() {
|
||||
setTimeout(() => w.print(), 300);
|
||||
}
|
||||
|
||||
/* ======================== SLIDE 1 — IDENTIFICATION ======================== */
|
||||
function selectFormateur(btn) {
|
||||
document.querySelectorAll('.formateur-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('idFormateur').value = btn.dataset.formateur;
|
||||
}
|
||||
function clearGroupeError() {
|
||||
const g = document.getElementById('idGroupe');
|
||||
g.style.borderColor = '';
|
||||
document.getElementById('idGroupe-error').hidden = true;
|
||||
}
|
||||
function startPart1() {
|
||||
const g = document.getElementById('idGroupe');
|
||||
if (!g.value.trim()) {
|
||||
g.style.borderColor = 'var(--coral-mid)';
|
||||
document.getElementById('idGroupe-error').hidden = false;
|
||||
g.focus();
|
||||
return;
|
||||
}
|
||||
goTo(2);
|
||||
}
|
||||
|
||||
/* ======================== NAVIGATION ======================== */
|
||||
function goTo(n) {
|
||||
const cur = document.getElementById('slide-' + currentSlide);
|
||||
@@ -1205,6 +1533,7 @@ function goTo(n) {
|
||||
const sa = next.querySelector('.scroll-area');
|
||||
if (sa) sa.scrollTop = 0;
|
||||
updateUI();
|
||||
if (n === 8) setTimeout(launchConfetti, 200);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -1220,20 +1549,68 @@ function goBack() {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
/* Boutons centralisés dans la bottom bar, un jeu par slide */
|
||||
function barBtn(bar, html, cls, fn, opts) {
|
||||
opts = opts || {};
|
||||
const b = document.createElement('button');
|
||||
b.className = 'footer-btn ' + cls;
|
||||
b.innerHTML = html;
|
||||
if (opts.id) b.id = opts.id;
|
||||
if (opts.disabled) b.disabled = true;
|
||||
b.addEventListener('click', fn);
|
||||
bar.appendChild(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function renderBarActions() {
|
||||
const bar = document.getElementById('barActions');
|
||||
if (!bar) return;
|
||||
bar.innerHTML = '';
|
||||
// Bouton retour intégré au groupe centré
|
||||
if (history.length > 0) {
|
||||
barBtn(bar, '← Retour', 'is-secondary', goBack);
|
||||
}
|
||||
switch (currentSlide) {
|
||||
case 1:
|
||||
barBtn(bar, 'Commencer la partie 1 →', 'is-primary', startPart1);
|
||||
break;
|
||||
case 2:
|
||||
barBtn(bar, 'Répondre aux questions →', 'is-primary', () => goTo(3));
|
||||
break;
|
||||
case 3:
|
||||
barBtn(bar, '↺ Recommencer', 'is-secondary', resetFDS);
|
||||
barBtn(bar, 'Passer à la partie 2 →', 'is-primary', () => goTo(4), { id: 'fds-next' });
|
||||
updateFdsGate();
|
||||
break;
|
||||
case 4:
|
||||
barBtn(bar, 'Répondre aux questions →', 'is-primary', () => goTo(5));
|
||||
break;
|
||||
case 5:
|
||||
barBtn(bar, '↺ Recommencer', 'is-secondary', resetFT);
|
||||
barBtn(bar, 'Passer à la partie 3 →', 'is-primary', () => goTo(6), { id: 'ft-next' });
|
||||
refreshFtState();
|
||||
break;
|
||||
case 6:
|
||||
barBtn(bar, 'Répondre aux questions →', 'is-primary', () => goTo(7));
|
||||
break;
|
||||
case 7:
|
||||
barBtn(bar, '↺ Recommencer', 'is-secondary', resetEtiquette);
|
||||
barBtn(bar, 'Terminer l\'exercice →', 'is-primary', () => goTo(8),
|
||||
{ id: 'eti-recap-btn', disabled: etiPos < etiOrder.length });
|
||||
break;
|
||||
case 8:
|
||||
// Boutons placés sous le tableau comparatif, dans la slide (pas dans la bottom bar)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
document.getElementById('progressFill').style.width =
|
||||
currentSlide === 0 ? '0%' : ((currentSlide / totalSlides) * 100) + '%';
|
||||
document.getElementById('slideCounter').textContent =
|
||||
currentSlide === 0 ? '' : currentSlide + ' / ' + totalSlides;
|
||||
document.getElementById('bottomBar').style.display = currentSlide === 0 ? 'none' : 'flex';
|
||||
document.getElementById('backBtn').style.display = history.length > 0 ? 'block' : 'none';
|
||||
|
||||
const badge = document.getElementById('phaseBadge');
|
||||
const set = (bg, col, txt) => { badge.style.display = 'block'; badge.style.background = bg; badge.style.color = col; badge.textContent = txt; };
|
||||
if (currentSlide === 2 || currentSlide === 3) set('var(--amber-light)', 'var(--amber-dark)', 'Partie 1 — FDS');
|
||||
else if (currentSlide === 4 || currentSlide === 5) set('var(--coral-light)', 'var(--coral)', 'Partie 2 — FT');
|
||||
else if (currentSlide === 6 || currentSlide === 7) set('var(--teal-light)', 'var(--teal-dark)', 'Partie 3 — Étiquette');
|
||||
else badge.style.display = 'none';
|
||||
renderBarActions();
|
||||
}
|
||||
|
||||
function restartAll() {
|
||||
@@ -1248,6 +1625,96 @@ function restartAll() {
|
||||
updateUI();
|
||||
}
|
||||
|
||||
/* ======================== CONFETTIS ======================== */
|
||||
function launchConfetti() {
|
||||
const canvas = document.getElementById('confettiCanvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = 'block';
|
||||
|
||||
const colors = ['#BA7517', '#D85A30', '#F5DFB8', '#633806', '#F0A855', '#E8C068', '#FF8C5A'];
|
||||
const particles = Array.from({ length: 180 }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: -(Math.random() * canvas.height * 0.5),
|
||||
w: Math.random() * 10 + 5,
|
||||
h: Math.random() * 5 + 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotSpeed: (Math.random() - 0.5) * 0.18,
|
||||
vx: (Math.random() - 0.5) * 2.5,
|
||||
vy: Math.random() * 3 + 2,
|
||||
opacity: 1,
|
||||
shape: Math.random() < 0.4 ? 'circle' : 'rect'
|
||||
}));
|
||||
|
||||
let rafId;
|
||||
let start = null;
|
||||
const totalDuration = 3800;
|
||||
|
||||
function draw(ts) {
|
||||
if (!start) start = ts;
|
||||
const elapsed = ts - start;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let active = false;
|
||||
|
||||
particles.forEach(p => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.04;
|
||||
p.rotation += p.rotSpeed;
|
||||
p.opacity = elapsed < totalDuration * 0.55
|
||||
? 1
|
||||
: Math.max(0, 1 - (elapsed - totalDuration * 0.55) / (totalDuration * 0.45));
|
||||
|
||||
if (p.y < canvas.height + 20) active = true;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rotation);
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = p.color;
|
||||
if (p.shape === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
if (active && elapsed < totalDuration * 1.3) {
|
||||
rafId = requestAnimationFrame(draw);
|
||||
} else {
|
||||
canvas.style.display = 'none';
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ======================== PROTECTION CONTRE LA PERTE DES RÉPONSES ======================== */
|
||||
/* Les réponses ne sont pas enregistrées : on avertit avant tout rechargement ou départ.
|
||||
Le texte de la boîte est imposé par le navigateur, il n'est pas personnalisable. */
|
||||
let reponsesModifiees = false;
|
||||
function marquerModifie() { reponsesModifiees = true; }
|
||||
|
||||
// Les champs texte déclenchent input/change ; les parties FDS (glisser-déposer),
|
||||
// FT (liaisons) et Étiquette (zones cliquables) appellent marquerModifie() directement.
|
||||
document.addEventListener('input', marquerModifie, true);
|
||||
document.addEventListener('change', marquerModifie, true);
|
||||
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!reponsesModifiees) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
generateQuizzes();
|
||||
updateUI();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
padding-top: calc(70px + 2rem);
|
||||
padding-bottom: calc(52px + 2rem);
|
||||
padding-bottom: calc(84px + 2rem);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
overflow-y: auto;
|
||||
@@ -38,73 +38,67 @@
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* PHASE INDICATOR */
|
||||
.phase-badge {
|
||||
position: fixed;
|
||||
top: 80px; right: 16px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* BOTTOM BAR */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 52px;
|
||||
height: 84px;
|
||||
background: rgba(250, 238, 218, 0.98);
|
||||
border-top: 2px solid var(--amber);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* SLIDE NAV */
|
||||
.bottom-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.footer-btn {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 11px 24px;
|
||||
border-radius: 40px;
|
||||
border: 1.5px solid var(--amber);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.footer-btn:hover { transform: translateY(-1px); }
|
||||
.footer-btn:active { transform: scale(0.98); }
|
||||
.footer-btn.is-primary { background: var(--amber); color: #fff; }
|
||||
.footer-btn.is-primary:hover { background: var(--amber-dark); }
|
||||
.footer-btn.is-secondary { background: transparent; color: var(--amber-dark); }
|
||||
.footer-btn.is-secondary:hover { background: rgba(186,117,23,0.08); }
|
||||
.slide-counter {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
font-size: 12px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 600;
|
||||
color: var(--amber-dark);
|
||||
}
|
||||
|
||||
/* BACK BUTTON */
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 0.5px solid var(--amber);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--amber-dark);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
@media (max-width: 560px) {
|
||||
.slide-counter { display: none; }
|
||||
}
|
||||
.back-btn:hover { background: rgba(186,117,23,0.08); }
|
||||
|
||||
/* FORMATEUR SELECTOR */
|
||||
.formateur-btn {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 10px 24px;
|
||||
border-radius: 30px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: white;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
flex: 1; font-family: 'DM Sans', sans-serif; font-size: 13.5px;
|
||||
padding: 9px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg); color: var(--text); cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.formateur-btn:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.formateur-btn.selected { background: var(--amber-light); border-color: var(--amber); color: var(--amber-dark); font-weight: 700; }
|
||||
.formateur-btn:hover { border-color: var(--amber); }
|
||||
.formateur-btn.selected { background: var(--amber); border-color: var(--amber); color: #fff; }
|
||||
|
||||
/* CARDS & BUTTONS */
|
||||
.btn {
|
||||
@@ -246,40 +240,42 @@
|
||||
.q-item { flex-direction: column; align-items: stretch; }
|
||||
.q-item .q-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
|
||||
/* IDENTIFICATION SCREEN */
|
||||
.id-form {
|
||||
/* IDENTIFICATION SCREEN — carte modèle BioCID / FDS */
|
||||
.identity-card {
|
||||
background: white;
|
||||
border: 0.5px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
max-width: 460px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 1.25rem 0;
|
||||
margin: 1.25rem auto;
|
||||
text-align: left;
|
||||
}
|
||||
.id-field label {
|
||||
display: block;
|
||||
.identity-card .identity-title {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
color: var(--amber-dark);
|
||||
margin-bottom: 10px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.id-field input {
|
||||
width: 100%;
|
||||
.identity-fields { display: flex; flex-direction: column; gap: 8px; }
|
||||
.identity-fields input {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 15px;
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 9px 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.id-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.identity-fields input:focus { outline: none; border-color: var(--amber); background: white; }
|
||||
.formateur-label { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted); margin: 4px 0 2px; }
|
||||
.formateur-row { display: flex; gap: 8px; }
|
||||
|
||||
/* TABLE FORM CONTROLS */
|
||||
.risk-table input, .risk-table textarea {
|
||||
@@ -496,6 +492,19 @@
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.info-card {
|
||||
background: var(--amber-light);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
max-width: 620px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--amber-dark);
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.steps-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -574,7 +583,7 @@
|
||||
gap: 12px;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
margin: 1.5rem 0;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
.final-card {
|
||||
background: white;
|
||||
@@ -606,7 +615,7 @@
|
||||
/* SCROLLABLE AREA */
|
||||
.scroll-area {
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 232px);
|
||||
max-height: calc(100vh - 200px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -647,13 +656,14 @@
|
||||
<!-- Progress bar -->
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
|
||||
<!-- Phase badge -->
|
||||
<div class="phase-badge" id="phaseBadge" style="display:none"></div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="bottom-bar" id="bottomBar" style="display:none;">
|
||||
<button class="back-btn" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
|
||||
<div class="slide-counter" id="slideCounter"></div>
|
||||
<div class="bottom-actions">
|
||||
<button class="footer-btn is-secondary" id="backBtn" onclick="goBack()" style="display:none;">← Retour</button>
|
||||
<button class="footer-btn is-secondary" id="footerHintBtn" onclick="showHintCurrent()" style="display:none;">💡 Besoin d'aide ?</button>
|
||||
<button class="footer-btn is-primary" id="footerNextBtn" onclick="goNext()" style="display:none;">Étape suivante →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal overlay -->
|
||||
@@ -666,48 +676,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 0 — IDENTIFICATION ======================== -->
|
||||
<!-- ======================== SLIDE 0 — ACCUEIL ======================== -->
|
||||
<div class="slide active fade-in" id="slide-0">
|
||||
<div class="deco-circle" style="width:320px;height:320px;background:var(--amber-light);top:-80px;left:-80px;opacity:0.5;"></div>
|
||||
<div style="text-align:center; z-index:1; width:100%;">
|
||||
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Avant de commencer</div>
|
||||
<div class="slide-title">Identification du groupe</div>
|
||||
<p class="slide-intro" style="margin:0.5rem auto 0;max-width:420px;">Ces informations permettront au formateur de retrouver vos réponses.</p>
|
||||
<div class="id-form" style="margin-left:auto;margin-right:auto;">
|
||||
<div class="id-field">
|
||||
<label for="inputNom">Nom du stagiaire ou du groupe</label>
|
||||
<input type="text" id="inputNom" placeholder="Ex : Groupe 3 - Dupont / Martin" autocomplete="off">
|
||||
</div>
|
||||
<div class="id-field">
|
||||
<label for="inputDate">Date de la session</label>
|
||||
<input type="date" id="inputDate">
|
||||
</div>
|
||||
<div class="id-field">
|
||||
<label>Formateur de la session</label>
|
||||
<div style="display:flex;gap:10px;margin-top:2px;">
|
||||
<button type="button" class="formateur-btn" id="btnNathan" onclick="selectFormateur('Nathan')">Nathan</button>
|
||||
<button type="button" class="formateur-btn" id="btnGauthier" onclick="selectFormateur('Gauthier')">Gauthier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="idError" style="color:var(--coral);font-size:13px;margin-bottom:0.5rem;display:none;">Merci de renseigner au moins le nom du groupe.</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
|
||||
<button class="btn btn-teal" onclick="confirmIdentite()">Continuer →</button>
|
||||
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
|
||||
</div>
|
||||
<div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div>
|
||||
<div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div>
|
||||
<div style="text-align:center;">
|
||||
<div class="hero-label">Certibiocide — Travaux pratiques</div>
|
||||
<div class="hero-title">Étude de cas<br>Nuisibles</div>
|
||||
<p class="hero-sub">Votre mission, si vous l'acceptez : maîtriser l'identification, le traitement et la sécurité face aux nuisibles.</p>
|
||||
<button class="btn btn-amber" onclick="goTo(1)">Démarrer la mission →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 1 — ACCUEIL ======================== -->
|
||||
<!-- ======================== SLIDE 1 — CONTEXTE & IDENTIFICATION ======================== -->
|
||||
<div class="slide" id="slide-1">
|
||||
<div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div>
|
||||
<div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div>
|
||||
<div style="text-align:center; z-index:1;">
|
||||
<div class="hero-label">Formation</div>
|
||||
<div class="hero-title">CERTIBIOCIDE</div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:clamp(14px,2.5vw,22px);font-weight:600;color:var(--amber-mid);letter-spacing:0.04em;margin-bottom:1.5rem;">TRAVAUX PRATIQUES</div>
|
||||
<p class="hero-sub">Votre mission, si vous l'acceptez : maîtriser l'identification, le traitement et la sécurité face aux nuisibles.</p>
|
||||
<button class="btn btn-teal" onclick="goTo(2)">Démarrer la mission →</button>
|
||||
<div class="deco-circle" style="width:300px;height:300px;background:var(--amber-light);top:-60px;left:-60px;opacity:0.4;"></div>
|
||||
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Avant de commencer</div>
|
||||
<div class="slide-title">Contexte & accès</div>
|
||||
<p class="slide-intro">3 travaux pratiques vous attendent : protocole d'intervention, recherche produit sur BioCID, puis analyse de risques. Ces informations permettront au formateur de retrouver vos réponses.</p>
|
||||
<div class="info-card" style="max-width:620px;">
|
||||
<span>🎯</span>
|
||||
<div>Vous allez définir un <strong>protocole d'intervention</strong>, trouver votre <strong>produit sur BioCID</strong>, puis réaliser l'<strong>analyse de risques</strong>.</div>
|
||||
</div>
|
||||
<div class="identity-card" style="max-width:620px;margin-top:0.5rem;">
|
||||
<div class="identity-title">👥 Identification du groupe</div>
|
||||
<div class="identity-fields">
|
||||
<input type="text" id="inputNom" placeholder="Nom du groupe / stagiaires (ex : Groupe 3 — Dupont / Martin)" autocomplete="off">
|
||||
<input type="date" id="inputDate">
|
||||
<div class="formateur-label">Formateur de la session</div>
|
||||
<div class="formateur-row">
|
||||
<button type="button" class="formateur-btn" id="btnNathan" onclick="selectFormateur('Nathan')">Nathan</button>
|
||||
<button type="button" class="formateur-btn" id="btnGauthier" onclick="selectFormateur('Gauthier')">Gauthier</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="idError" style="color:var(--coral);font-size:13px;margin-top:8px;display:none;">Merci de renseigner au moins le nom du groupe.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -715,8 +717,12 @@
|
||||
<div class="slide" id="slide-2">
|
||||
<div class="deco-circle" style="width:300px;height:300px;background:var(--amber-light);top:-60px;left:-60px;opacity:0.4;"></div>
|
||||
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Étape 1 / 3</div>
|
||||
<div class="slide-title">Choisissez votre mission</div>
|
||||
<div class="slide-title">🎯 Choisissez votre mission</div>
|
||||
<p class="slide-intro">Chaque groupe choisit un cas pratique. Vous travaillerez avec ce scénario tout au long des 3 exercices.</p>
|
||||
<div class="info-card" style="margin-bottom:1.25rem;">
|
||||
<span>💡</span>
|
||||
<div>Le cas retenu conditionne <strong>tous les exercices suivants</strong> et apparaît dans le PDF final. Choisissez celui qui se rapproche le plus de votre pratique.</div>
|
||||
</div>
|
||||
<div class="case-grid">
|
||||
<div class="case-card" onclick="selectCase('punaises','🛏️','Hôtel — Punaises de lit','Infestation importante dans un hôtel')">
|
||||
<div class="case-icon">🛏️</div>
|
||||
@@ -744,9 +750,6 @@
|
||||
<div class="case-sub">Camping</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-teal" id="btnConfirmCase" onclick="goTo(3)" style="display:none">Confirmer le choix →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 3 — CONSIGNES ======================== -->
|
||||
@@ -774,7 +777,6 @@
|
||||
<span>💡</span>
|
||||
<div>À tout moment, cliquez sur <strong>« Besoin d'aide ? »</strong> pour accéder à un indice. Le formateur corrigera à l'oral en fin de séance.</div>
|
||||
</div>
|
||||
<button class="btn btn-teal" onclick="goTo(4)">Commencer le TP 1 →</button>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 4 — TP1 INTRO ======================== -->
|
||||
@@ -792,7 +794,6 @@
|
||||
<div class="step-pill">2. Intervention</div>
|
||||
<div class="step-pill">3. Évaluation</div>
|
||||
</div>
|
||||
<button class="btn btn-amber" onclick="goTo(5)">Commencer →</button>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 5 — TP1 PREVENTION ======================== -->
|
||||
@@ -833,10 +834,6 @@
|
||||
<textarea class="answer-field" id="ans_prevention_4" placeholder="Votre réponse…" data-question="Éléments observés / preuve de présence du nuisible"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('prevention')">💡 Besoin d'aide ?</button>
|
||||
<button class="btn btn-amber" onclick="goTo(6)">Étape suivante →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -871,10 +868,6 @@
|
||||
<textarea class="answer-field" id="ans_intervention_3" placeholder="Votre réponse…" data-question="Type de produit (TP) et mode de traitement"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('intervention')">💡 Besoin d'aide ?</button>
|
||||
<button class="btn btn-amber" onclick="goTo(7)">Étape suivante →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -905,10 +898,6 @@
|
||||
<div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:680px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.75rem 0;">
|
||||
✅ <strong>TP 1 terminé !</strong> Préparez-vous à identifier le produit adapté à votre cas sur biocid-anses.fr.
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('evaluation')">💡 Besoin d'aide ?</button>
|
||||
<button class="btn btn-coral" onclick="goTo(8)">Passer au TP 2 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -930,7 +919,6 @@
|
||||
</div>
|
||||
<a href="https://biocid-anses.fr" target="_blank" class="resource-link">Ouvrir le site ↗</a>
|
||||
</div>
|
||||
<button class="btn btn-coral" onclick="goTo(9)">Commencer →</button>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 9 — TP2 FDS & AMM ======================== -->
|
||||
@@ -971,10 +959,6 @@
|
||||
<textarea class="answer-field" id="ans_fds_4" placeholder="Votre réponse…" data-question="Risques pour la santé"></textarea>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('fds')">💡 Besoin d'aide ?</button>
|
||||
<button class="btn btn-coral" onclick="goTo(10)">Partie suivante →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1019,10 +1003,6 @@
|
||||
<div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:680px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.75rem 0;">
|
||||
✅ <strong>TP 2 terminé !</strong> Passez maintenant à l'analyse de risques complète.
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('epi')">💡 Besoin d'aide ?</button>
|
||||
<button class="btn btn-purple" onclick="goTo(11)">Passer au TP 3 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1039,9 +1019,6 @@
|
||||
<div class="pyr-level" style="width:320px;background:#D85A30;">Communication</div>
|
||||
<div class="pyr-level" style="width:360px;background:#BA7517;">EPI (dernier recours)</div>
|
||||
</div>
|
||||
<div class="actions-row" style="margin-top:1rem;">
|
||||
<button class="btn btn-purple" onclick="goTo(12)">Remplir le tableau →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 12 — TP3 TABLEAU ======================== -->
|
||||
@@ -1110,10 +1087,6 @@
|
||||
<div style="background:var(--amber-light);border-radius:12px;padding:14px 18px;max-width:780px;width:100%;font-size:13px;color:var(--amber-dark);margin:0.25rem 0;">
|
||||
✅ <strong>TP 3 terminé !</strong> Présentez vos réponses au formateur pour la correction.
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button class="hint-btn" onclick="showHint('analyse')">💡 Aide générale</button>
|
||||
<button class="btn btn-blue" onclick="goTo(13)">Terminer la mission →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1143,15 +1116,10 @@
|
||||
<div class="final-card-sub">Analyse de risques</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="submit-zone">
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
|
||||
<button class="btn btn-teal" onclick="exporterPDF()">⬇ Télécharger en PDF</button>
|
||||
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:0.75rem;">
|
||||
<a href="https://biocid-anses.fr" target="_blank" class="btn btn-outline btn-teal" style="font-size:13px;padding:10px 20px;">biocid-anses.fr ↗</a>
|
||||
<button class="btn btn-teal" onclick="restartAll()">↺ Recommencer depuis le début</button>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
|
||||
<a href="corriger-exercice.html?exo=tp" class="btn btn-outline btn-teal">✔ Corriger un rendu</a>
|
||||
<button class="btn btn-outline btn-teal" onclick="restartAll()">↺ Recommencer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1164,6 +1132,22 @@ const totalSlides = 13;
|
||||
const selectedCase = { key: '', icon: '', title: '', desc: '' };
|
||||
const stagiaire = { nom: '', date: '', formateur: '' };
|
||||
|
||||
// Configuration des boutons du footer par slide (modèle BioCID)
|
||||
const slideConfig = {
|
||||
1: { next: 2, nextLabel: 'Continuer →' },
|
||||
2: { next: 3, nextLabel: 'Confirmer le choix →' },
|
||||
3: { next: 4, nextLabel: 'Commencer le TP 1 →' },
|
||||
4: { next: 5, nextLabel: 'Commencer →' },
|
||||
5: { next: 6, nextLabel: 'Étape suivante →', hint: 'prevention' },
|
||||
6: { next: 7, nextLabel: 'Étape suivante →', hint: 'intervention' },
|
||||
7: { next: 8, nextLabel: 'Passer au TP 2 →', hint: 'evaluation' },
|
||||
8: { next: 9, nextLabel: 'Commencer →' },
|
||||
9: { next: 10, nextLabel: 'Partie suivante →', hint: 'fds' },
|
||||
10: { next: 11, nextLabel: 'Passer au TP 3 →', hint: 'epi' },
|
||||
11: { next: 12, nextLabel: 'Remplir le tableau →' },
|
||||
12: { next: 13, nextLabel: 'Terminer la mission →', hint: 'analyse' }
|
||||
};
|
||||
|
||||
const hints = {
|
||||
prevention: {
|
||||
icon: '💡',
|
||||
@@ -1250,11 +1234,28 @@ function goTo(n) {
|
||||
currentSlide = n;
|
||||
next.style.display = 'flex';
|
||||
setTimeout(() => { next.classList.add('active', 'fade-in'); }, 10);
|
||||
const sa = next.querySelector('.scroll-area');
|
||||
if (sa) sa.scrollTop = 0;
|
||||
if (n === 13) setTimeout(launchConfetti, 200);
|
||||
updateUI();
|
||||
updateCaseBanners();
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const cfg = slideConfig[currentSlide];
|
||||
if (!cfg || cfg.next == null) return;
|
||||
// Slide 1 : validation du nom du groupe avant de continuer
|
||||
if (currentSlide === 1) { confirmIdentite(); return; }
|
||||
// Slide 2 : un cas doit être sélectionné
|
||||
if (currentSlide === 2 && !selectedCase.key) return;
|
||||
goTo(cfg.next);
|
||||
}
|
||||
|
||||
function showHintCurrent() {
|
||||
const cfg = slideConfig[currentSlide];
|
||||
if (cfg && cfg.hint) showHint(cfg.hint);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (history.length === 0) return;
|
||||
const prev = history.pop();
|
||||
@@ -1279,37 +1280,31 @@ function updateUI() {
|
||||
if (bottomBar) bottomBar.style.display = currentSlide === 0 ? 'none' : 'flex';
|
||||
|
||||
const backBtn = document.getElementById('backBtn');
|
||||
backBtn.style.display = history.length > 0 ? 'block' : 'none';
|
||||
backBtn.style.display = history.length > 0 ? 'inline-flex' : 'none';
|
||||
|
||||
const badge = document.getElementById('phaseBadge');
|
||||
if (currentSlide === 0 || currentSlide === 1 || currentSlide === 2 || currentSlide === 3 || currentSlide === 13) {
|
||||
badge.style.display = 'none';
|
||||
} else if (currentSlide >= 4 && currentSlide <= 7) {
|
||||
badge.style.display = 'block';
|
||||
badge.style.background = 'var(--amber-light)';
|
||||
badge.style.color = 'var(--amber-dark)';
|
||||
badge.textContent = 'TP 1 — Protocole';
|
||||
} else if (currentSlide >= 8 && currentSlide <= 10) {
|
||||
badge.style.display = 'block';
|
||||
badge.style.background = 'var(--coral-light)';
|
||||
badge.style.color = 'var(--coral)';
|
||||
badge.textContent = 'TP 2 — Produit';
|
||||
} else if (currentSlide >= 11 && currentSlide <= 12) {
|
||||
badge.style.display = 'block';
|
||||
badge.style.background = 'var(--amber-light)';
|
||||
badge.style.color = 'var(--amber-dark)';
|
||||
badge.textContent = 'TP 3 — Risques';
|
||||
const cfg = slideConfig[currentSlide] || {};
|
||||
const hintBtn = document.getElementById('footerHintBtn');
|
||||
const nextBtn = document.getElementById('footerNextBtn');
|
||||
hintBtn.style.display = cfg.hint ? 'inline-flex' : 'none';
|
||||
if (cfg.next != null) {
|
||||
// Slide 2 : le bouton n'apparaît qu'une fois le cas choisi
|
||||
const gated = currentSlide === 2 && !selectedCase.key;
|
||||
nextBtn.style.display = gated ? 'none' : 'inline-flex';
|
||||
nextBtn.textContent = cfg.nextLabel;
|
||||
} else {
|
||||
nextBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function selectCase(key, icon, title, desc) {
|
||||
marquerModifie();
|
||||
selectedCase.key = key;
|
||||
selectedCase.icon = icon;
|
||||
selectedCase.title = title;
|
||||
selectedCase.desc = desc;
|
||||
document.querySelectorAll('.case-card').forEach(c => c.classList.remove('selected'));
|
||||
event.currentTarget.classList.add('selected');
|
||||
document.getElementById('btnConfirmCase').style.display = 'inline-flex';
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function updateCaseBanners() {
|
||||
@@ -1379,7 +1374,7 @@ function confirmIdentite() {
|
||||
document.getElementById('idError').style.display = 'none';
|
||||
stagiaire.nom = nom;
|
||||
stagiaire.date = date || new Date().toISOString().slice(0, 10);
|
||||
goTo(1);
|
||||
goTo(2);
|
||||
}
|
||||
|
||||
/* ======================== TABLEAU TP3 — LIGNES DYNAMIQUES ======================== */
|
||||
@@ -1606,6 +1601,156 @@ function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ======================== SAUVEGARDE AUTOMATIQUE ======================== */
|
||||
/* Les réponses sont enregistrées dans le navigateur au fil de la saisie, puis
|
||||
proposées à la restauration au rechargement. En file://, localStorage peut être
|
||||
indisponible (Safari) : dans ce cas la page fonctionne normalement, sans filet. */
|
||||
const CLE_SAUVEGARDE = 'certibiocide:tp-nuisibles:v1';
|
||||
|
||||
let reponsesModifiees = false;
|
||||
function marquerModifie() { reponsesModifiees = true; sauvegarder(); }
|
||||
|
||||
document.addEventListener('input', marquerModifie, true);
|
||||
document.addEventListener('change', marquerModifie, true);
|
||||
|
||||
// Avertit avant tout rechargement ou départ. Le texte de la boîte est imposé
|
||||
// par le navigateur, il n'est pas personnalisable.
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!reponsesModifiees) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
|
||||
function etatCourant() {
|
||||
const champs = {};
|
||||
document.querySelectorAll('input[id], textarea[id]').forEach(el => {
|
||||
if (el.value) champs[el.id] = el.value;
|
||||
});
|
||||
// Les cellules du tableau TP3 n'ont pas d'id : on les repère par leur position.
|
||||
const risques = Array.from(document.querySelectorAll('#riskTableBody .risk-row'))
|
||||
.map(row => Array.from(row.querySelectorAll('.risk-cell')).map(c => c.value));
|
||||
|
||||
return {
|
||||
savedAt: new Date().toISOString(),
|
||||
slide: currentSlide,
|
||||
champs,
|
||||
risques,
|
||||
cas: selectedCase.key || '',
|
||||
formateur: stagiaire.formateur || '',
|
||||
nom: stagiaire.nom || '',
|
||||
date: stagiaire.date || '',
|
||||
};
|
||||
}
|
||||
|
||||
let minuteurSauvegarde = null;
|
||||
function sauvegarder() {
|
||||
clearTimeout(minuteurSauvegarde);
|
||||
minuteurSauvegarde = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(CLE_SAUVEGARDE, JSON.stringify(etatCourant()));
|
||||
} catch (e) { /* navigation privée ou quota : on continue sans sauvegarde */ }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function lireSauvegarde() {
|
||||
try { return JSON.parse(localStorage.getItem(CLE_SAUVEGARDE)); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function effacerSauvegarde() {
|
||||
try { localStorage.removeItem(CLE_SAUVEGARDE); } catch (e) {}
|
||||
}
|
||||
|
||||
function appliquerSauvegarde(data) {
|
||||
Object.entries(data.champs || {}).forEach(([id, v]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = v;
|
||||
});
|
||||
|
||||
// Recrée autant de lignes de risques que nécessaire avant de les remplir
|
||||
const risques = data.risques || [];
|
||||
const tbody = document.getElementById('riskTableBody');
|
||||
while (tbody.querySelectorAll('.risk-row').length < risques.length) addRiskRow();
|
||||
tbody.querySelectorAll('.risk-row').forEach((row, i) => {
|
||||
const valeurs = risques[i] || [];
|
||||
row.querySelectorAll('.risk-cell').forEach((cell, j) => { cell.value = valeurs[j] || ''; });
|
||||
});
|
||||
|
||||
// Le cas pratique et le formateur passent par leurs boutons, qui remettent
|
||||
// aussi l'interface (carte sélectionnée, bouton de confirmation) dans le bon état.
|
||||
if (data.cas) {
|
||||
const carte = Array.from(document.querySelectorAll('.case-card'))
|
||||
.find(c => (c.getAttribute('onclick') || '').includes("'" + data.cas + "'"));
|
||||
if (carte) carte.click();
|
||||
}
|
||||
if (data.formateur) {
|
||||
const btn = document.getElementById('btn' + data.formateur);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
stagiaire.nom = data.nom || stagiaire.nom;
|
||||
stagiaire.date = data.date || stagiaire.date;
|
||||
|
||||
reponsesModifiees = true;
|
||||
if (typeof data.slide === 'number' && data.slide > 0) goTo(data.slide);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
/* Bandeau de restauration : on ne restaure jamais sans demander, pour qu'un
|
||||
nouveau groupe n'hérite pas des réponses du précédent sur un poste partagé. */
|
||||
function injecterStylesBandeau() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.restore-bar {
|
||||
position: fixed; top: 84px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 900; max-width: min(680px, calc(100vw - 32px));
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
background: var(--white); border: 1.5px solid var(--amber);
|
||||
border-radius: 14px; padding: 14px 18px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12); font-size: 13.5px;
|
||||
}
|
||||
.restore-bar .restore-text { flex: 1; min-width: 220px; line-height: 1.5; }
|
||||
.restore-bar .restore-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.restore-bar button {
|
||||
font-family: 'Syne', sans-serif; font-weight: 700; font-size: 12.5px;
|
||||
padding: 8px 16px; border-radius: 20px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: transparent; color: var(--text-muted);
|
||||
}
|
||||
.restore-bar button:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.restore-bar button.primary {
|
||||
background: var(--amber); border-color: var(--amber); color: #fff;
|
||||
}
|
||||
.restore-bar button.primary:hover { background: var(--amber-dark); border-color: var(--amber-dark); color: #fff; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function proposerRestauration() {
|
||||
const data = lireSauvegarde();
|
||||
if (!data) return;
|
||||
const aDesReponses = (data.champs && Object.keys(data.champs).length > 0)
|
||||
|| (data.risques || []).some(r => r.some(v => v));
|
||||
if (!aDesReponses) return;
|
||||
|
||||
const quand = new Date(data.savedAt);
|
||||
const libelle = isNaN(quand.getTime()) ? '' :
|
||||
` du ${quand.toLocaleDateString('fr-FR')} à ${quand.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
injecterStylesBandeau();
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'restore-bar';
|
||||
bar.innerHTML = `
|
||||
<span class="restore-text">💾 Des réponses enregistrées${libelle} ont été retrouvées sur ce poste.</span>
|
||||
<span class="restore-actions">
|
||||
<button type="button" class="primary" id="btnRestaurer">Restaurer</button>
|
||||
<button type="button" id="btnRepartir">Repartir de zéro</button>
|
||||
</span>`;
|
||||
document.body.appendChild(bar);
|
||||
|
||||
document.getElementById('btnRestaurer').onclick = () => { appliquerSauvegarde(data); bar.remove(); };
|
||||
document.getElementById('btnRepartir').onclick = () => { effacerSauvegarde(); bar.remove(); };
|
||||
}
|
||||
|
||||
proposerRestauration();
|
||||
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction BioCID — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL DE THEMES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
|
||||
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
|
||||
<h1>Plateforme BioCID — Lecture du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice BioCID</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> questions renseignées</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
// Les 4 exercices, avec les intitulés exacts exportés (data-question) comme clés.
|
||||
const THEMES = [
|
||||
{
|
||||
title: "Exercice 1 — Recherche par nom",
|
||||
icon: "🔎", color: "amber",
|
||||
items: [
|
||||
"Ex1 — Produit : type, substance active, fabricant",
|
||||
"Ex1 — Date d'autorisation",
|
||||
"Ex1 — Conditions d'utilisation",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 2 — Recherche par type",
|
||||
icon: "🗂️", color: "blue",
|
||||
items: [
|
||||
"Ex2 — Nombre de produits dans la catégorie",
|
||||
"Ex2 — Comparaison entre catégories",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 3 — Recherche par substance",
|
||||
icon: "🧪", color: "teal",
|
||||
items: [
|
||||
"Ex3 — 3 produits contenant la substance",
|
||||
"Ex3 — Formulations et concentrations comparées",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 4 — Cas de synthèse",
|
||||
icon: "📞", color: "coral",
|
||||
items: [
|
||||
"Ex4 — 3 produits applicables",
|
||||
"Ex4 — Fiche de chaque produit",
|
||||
"Ex4 — Argumentaire (+ bonus FDS)",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
|
||||
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(sans réponse\)/i.test(t);
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Retire les en-têtes/pieds d'impression du navigateur (date, titre, about:blank) sans toucher au corps
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/\s+\d{1,3}\/\d{1,3}\s*$/, " ");
|
||||
clean = clean.replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Réponses aux exercices"]),
|
||||
};
|
||||
|
||||
// Sépare "Groupe N" du nom des participants
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
const startIdx = clean.search(/Réponses aux exercices/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
|
||||
|
||||
const answers = {};
|
||||
for (let i = 0; i < ALL_SECTIONS.length; i++) {
|
||||
const label = ALL_SECTIONS[i];
|
||||
const nextLabel = ALL_SECTIONS[i + 1];
|
||||
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
|
||||
}
|
||||
|
||||
return { meta, answers };
|
||||
}
|
||||
|
||||
let currentThemeData = null;
|
||||
let currentTheme = 0;
|
||||
|
||||
function renderThemePanel() {
|
||||
const theme = THEMES[currentTheme];
|
||||
const data = currentThemeData;
|
||||
const colors = COLOR_MAP[theme.color];
|
||||
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
|
||||
<div class="theme-title">${theme.title}</div>
|
||||
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${theme.items.map(label => {
|
||||
const value = data.answers[label] || "";
|
||||
const empty = isEmpty(value);
|
||||
const displayLabel = label.replace(/^Ex\d+\s*[—-]\s*/, "");
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(displayLabel)}</div>
|
||||
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentTheme === 0;
|
||||
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= THEMES.length) return;
|
||||
currentTheme = idx;
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentThemeData = data;
|
||||
currentTheme = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
|
||||
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
|
||||
|
||||
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderThemePanel();
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export de l'exercice BioCID.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,604 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction FDS, FT & Étiquettes — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* SCORE GLOBAL */
|
||||
.score-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.score-ring {
|
||||
width: 84px; height: 84px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
font-family: 'Syne', sans-serif;
|
||||
}
|
||||
.score-ring .score-pct { font-size: 22px; font-weight: 800; line-height: 1; }
|
||||
.score-ring .score-lbl { font-size: 9px; letter-spacing: 0.06em; opacity: 0.9; }
|
||||
.score-detail .score-main { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 800; }
|
||||
.score-detail .score-sub { font-size: 13px; color: var(--text-muted); margin-top: 3px; }
|
||||
.score-parts { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.score-pill {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* CARROUSEL DE PARTIES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 5px; }
|
||||
.qcard .qgroup { font-size: 13px; }
|
||||
.qcard .qgroup.ok { color: var(--teal); }
|
||||
.qcard .qgroup.ko { color: var(--coral); }
|
||||
.qcard .qgroup.miss { color: var(--text-muted); font-style: italic; }
|
||||
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction de TD</div>
|
||||
<h1>FDS, FT & Étiquettes — Correction du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TD FDS, FT & Étiquettes</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="score-card" id="scoreCard"></div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentPart - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentPart + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
/* ==== Clé de correction (identique à l'exercice) ==== */
|
||||
const fdsQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
|
||||
{ id: 2, text: "Quels sont les dangers du produit ?", answer: "2" },
|
||||
{ id: 3, text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
|
||||
{ id: 4, text: "Quels gestes adopter en premiers secours ?", answer: "4" },
|
||||
{ id: 5, text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
|
||||
{ id: 6, text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
|
||||
{ id: 7, text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
|
||||
{ id: 8, text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
|
||||
{ id: 9, text: "Quel est le pH de ce produit ?", answer: "9" },
|
||||
{ id: 10, text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
|
||||
{ id: 11, text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
|
||||
{ id: 12, text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
|
||||
{ id: 13, text: "Comment éliminer le produit ?", answer: "13" },
|
||||
{ id: 14, text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
|
||||
{ id: 15, text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
|
||||
{ id: 16, text: "Où trouver le mode d'emploi du produit ?", answer: "absent" }
|
||||
];
|
||||
const ftQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
|
||||
{ id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
|
||||
{ id: 3, text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
|
||||
{ id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
|
||||
{ id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" }
|
||||
];
|
||||
const etiquetteQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
|
||||
{ id: 2, text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
|
||||
{ id: 3, text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
|
||||
{ id: 4, text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
|
||||
{ id: 5, text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
|
||||
{ id: 6, text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
|
||||
{ id: 7, text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
|
||||
{ id: 8, text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
|
||||
{ id: 9, text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
|
||||
{ id: 10, text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" }
|
||||
];
|
||||
|
||||
const PARTS = [
|
||||
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
|
||||
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
|
||||
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
|
||||
];
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
// Extrait la réponse du groupe pour un marqueur [Px-Qn]
|
||||
function extractSelected(fullText, marker, nextMarker) {
|
||||
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
|
||||
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
/* ==== Correction ==== */
|
||||
function normFdsToken(sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return null;
|
||||
if (/^absent$/i.test(sel.trim())) return "absent";
|
||||
const m = /(\d+)/.exec(sel);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function scoreFds(q, sel) {
|
||||
const tok = normFdsToken(sel);
|
||||
if (tok === null) return { state: "miss" };
|
||||
return { state: q.answer.split(",").includes(tok) ? "ok" : "ko" };
|
||||
}
|
||||
function scoreText(q, sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return { state: "miss" };
|
||||
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
|
||||
return { state: norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko" };
|
||||
}
|
||||
function expectedLabel(part, q) {
|
||||
if (part.kind === "fds") {
|
||||
const parts = q.answer.split(",");
|
||||
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
|
||||
return parts[0] === "absent" ? "Absent" : "Rubrique " + parts[0];
|
||||
}
|
||||
return q.answer;
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Nettoie les en-têtes/pieds d'impression du navigateur sans toucher au corps du document
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/FDS, FT (&|&) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Score global"]),
|
||||
};
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
// Liste ordonnée de tous les marqueurs pour borner chaque extraction
|
||||
const markers = [];
|
||||
PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
|
||||
|
||||
const selected = {};
|
||||
let mIdx = 0;
|
||||
PARTS.forEach(p => {
|
||||
p.questions.forEach((q, i) => {
|
||||
const marker = "[" + p.prefix + "-Q" + (i + 1) + "]";
|
||||
const next = markers[mIdx + 1];
|
||||
selected[marker] = extractSelected(clean, marker, next);
|
||||
mIdx++;
|
||||
});
|
||||
});
|
||||
|
||||
return { meta, selected };
|
||||
}
|
||||
|
||||
let currentData = null;
|
||||
let currentPart = 0;
|
||||
|
||||
function partResults(part) {
|
||||
return part.questions.map((q, i) => {
|
||||
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
|
||||
const sel = (currentData.selected[marker] || "").trim();
|
||||
const res = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
|
||||
return { q, i, sel, state: res.state };
|
||||
});
|
||||
}
|
||||
|
||||
function renderPart() {
|
||||
const part = PARTS[currentPart];
|
||||
const colors = COLOR_MAP[part.color];
|
||||
const results = partResults(part);
|
||||
const ok = results.filter(r => r.state === "ok").length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${part.icon}</div>
|
||||
<div class="theme-title">${part.title}</div>
|
||||
<div class="theme-count">${ok}/${results.length} corrects</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${results.map(r => {
|
||||
const badge = r.state === "ok" ? "ok" : (r.state === "ko" ? "ko" : "miss");
|
||||
const badgeIcon = r.state === "ok" ? "✓" : (r.state === "ko" ? "✗" : "?");
|
||||
const groupTxt = r.state === "miss" ? "Sans réponse" : escapeHtml(r.sel);
|
||||
const showExpected = r.state !== "ok";
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${badge}">${badgeIcon}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">Q${r.i + 1}. ${escapeHtml(r.q.text)}</div>
|
||||
<div class="qgroup ${badge}">Réponse du groupe : ${groupTxt}</div>
|
||||
${showExpected ? `<div class="qexpected">Attendu : ${escapeHtml(expectedLabel(part, r.q))}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentPart === 0;
|
||||
document.getElementById("nextBtn").disabled = currentPart === PARTS.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentPart));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= PARTS.length) return;
|
||||
currentPart = idx;
|
||||
renderPart();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentData = data;
|
||||
currentPart = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
// Score global + par partie
|
||||
let totalOk = 0, totalNb = 0;
|
||||
const pillHtml = PARTS.map(p => {
|
||||
const results = partResults(p);
|
||||
const ok = results.filter(r => r.state === "ok").length;
|
||||
totalOk += ok; totalNb += results.length;
|
||||
return `<div class="score-pill">${p.icon} ${ok}/${results.length}</div>`;
|
||||
}).join("");
|
||||
|
||||
const pct = totalNb ? Math.round((totalOk / totalNb) * 100) : 0;
|
||||
const ringColor = pct >= 80 ? "var(--teal-mid)" : (pct >= 60 ? "var(--amber)" : "var(--coral-mid)");
|
||||
const verdict = pct >= 80 ? "Très bon rendu" : (pct >= 60 ? "Rendu correct" : "À revoir en priorité");
|
||||
|
||||
document.getElementById("scoreCard").innerHTML = `
|
||||
<div class="score-ring" style="background:${ringColor};">
|
||||
<div class="score-pct">${pct}%</div>
|
||||
<div class="score-lbl">RÉUSSITE</div>
|
||||
</div>
|
||||
<div class="score-detail">
|
||||
<div class="score-main">${totalOk} / ${totalNb} bonnes réponses</div>
|
||||
<div class="score-sub">${verdict}</div>
|
||||
</div>
|
||||
<div class="score-parts">${pillHtml}</div>
|
||||
`;
|
||||
|
||||
document.getElementById("dots").innerHTML = PARTS.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderPart();
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TD FDS, FT & Étiquettes.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,650 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction TP Nuisibles — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
.id-card { display: none; }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.id-card .label {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--amber-dark);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.id-card .value { font-size: 14.5px; margin-top: 2px; }
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--teal); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL DE THEMES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 420px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
|
||||
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* TABLEAU ANALYSE DE RISQUES */
|
||||
.risk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.risk-table th {
|
||||
font-family: 'Syne', sans-serif;
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--amber-dark);
|
||||
background: var(--amber-light);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.risk-table th:first-child { border-radius: 8px 0 0 8px; }
|
||||
.risk-table th:last-child { border-radius: 0 8px 8px 0; }
|
||||
.risk-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.risk-table tr:last-child td { border-bottom: none; }
|
||||
.meta-line { font-size: 12.5px; color: var(--text-muted); margin-top: 20px; }
|
||||
.meta-line strong { color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction de TP</div>
|
||||
<h1>TP Nuisibles — Lecture du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du stagiaire ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TP</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="id-card" id="idCard"></div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> questions répondues</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="syne" style="font-size:16px;font-weight:800;margin-bottom:12px;color:var(--amber-dark);">Analyse de risques</h2>
|
||||
<div class="table-wrap" id="tp3Meta"></div>
|
||||
<div class="table-wrap" id="tp3Table" style="display:none;"></div>
|
||||
<p class="meta-line" id="suiviLine"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
// Thèmes regroupant les sections du TP, avec icône et couleur
|
||||
const THEMES = [
|
||||
{
|
||||
title: "Premier contact & diagnostic",
|
||||
icon: "🗣️", color: "purple",
|
||||
items: [
|
||||
"Questions posées au client lors du premier contact",
|
||||
"Conseils donnés en premier lieu",
|
||||
"Actions de prévention menées sur le site",
|
||||
"Éléments observés / preuve de présence du nuisible",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Solutions envisagées",
|
||||
icon: "🧰", color: "blue",
|
||||
items: [
|
||||
"Solutions alternatives non-biocides",
|
||||
"Solutions biocides mises en place",
|
||||
"Type de produit (TP) et mode de traitement",
|
||||
"Suivi mis en place et durée",
|
||||
"Gestion des déchets",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Produit & traçabilité",
|
||||
icon: "🧪", color: "teal",
|
||||
items: [
|
||||
"FDS trouvée (éléments principaux)",
|
||||
"Nom du produit et fabricant/fournisseur",
|
||||
"Numéro AMM",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Sécurité & santé",
|
||||
icon: "🛡️", color: "coral",
|
||||
items: [
|
||||
"Risques pour la santé",
|
||||
"EPI à porter",
|
||||
"Risques environnement / dispersion accidentelle",
|
||||
"DL50 substance(s) active(s)",
|
||||
"Gestes de premiers secours",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
|
||||
|
||||
const COLOR_MAP = {
|
||||
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(sans réponse\)$/i.test(t);
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Retire les répétitions d'en-tête de page (ex: "17/06/2026 15:42 Résultats TP — ... about:blank 1/2")
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2}\s+Résultats TP[^]*?about:blank\s*\d\/\d/gi, " ");
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Cas pratique"]),
|
||||
cas: captureBetween(clean, "Cas pratique", ["Réponses TP1"]),
|
||||
};
|
||||
|
||||
// Sépare "Groupe N" du nom des participants (ex: "Groupe 1 Romain Kevin Valere Greg P")
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
|
||||
const endIdx = clean.search(/TP3\s*[—-]\s*Analyse de risques/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
|
||||
|
||||
const answers = {};
|
||||
for (let i = 0; i < ALL_SECTIONS.length; i++) {
|
||||
const label = ALL_SECTIONS[i];
|
||||
const nextLabel = ALL_SECTIONS[i + 1];
|
||||
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
|
||||
}
|
||||
|
||||
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
|
||||
const tp3 = {
|
||||
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
|
||||
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
|
||||
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
|
||||
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
|
||||
};
|
||||
|
||||
// Tableau de risques : on découpe en s'appuyant sur les intitulés d'étapes connus du TP
|
||||
const KNOWN_STEPS = [
|
||||
"remplir la poudreuse",
|
||||
"sécurisation du site",
|
||||
"application traitements",
|
||||
"rangement du matériel. purger la perche",
|
||||
"retrait du nid",
|
||||
"gestion des déchets. élimination par des filiaires spé.",
|
||||
];
|
||||
let riskRows = [];
|
||||
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
|
||||
if (tableMatch) {
|
||||
let raw = tableMatch[1].trim();
|
||||
if (raw) {
|
||||
// Repère les positions de chaque étape connue dans le texte aplati
|
||||
const positions = [];
|
||||
KNOWN_STEPS.forEach(step => {
|
||||
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
|
||||
if (idx >= 0) positions.push({ step, idx });
|
||||
});
|
||||
positions.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
if (positions.length > 0) {
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const from = positions[i].idx + positions[i].step.length;
|
||||
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
|
||||
let rest = raw.slice(from, to).trim();
|
||||
// rest contient "risque... mesure..." -> on coupe sur les mots clés de mesures (gants/masques, barrieres, etc.)
|
||||
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
|
||||
const mm = measureRx.exec(rest);
|
||||
let risque = rest, mesure = "";
|
||||
if (mm) {
|
||||
risque = rest.slice(0, mm.index).trim();
|
||||
mesure = rest.slice(mm.index).trim();
|
||||
}
|
||||
risque = risque.replace(/^[\s\-–.]+/, "").trim();
|
||||
riskRows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
|
||||
}
|
||||
} else {
|
||||
riskRows.push({ etape: raw, risque: "", mesure: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
|
||||
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
|
||||
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
|
||||
|
||||
return {
|
||||
meta, answers, tp3, riskRows,
|
||||
suivi: {
|
||||
realise: mReal ? mReal[1].trim() : "",
|
||||
verifie: mVerif ? mVerif[1].trim() : "",
|
||||
maj: mMaj ? mMaj[1].trim() : "",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let currentThemeData = null;
|
||||
let currentTheme = 0;
|
||||
|
||||
function renderThemePanel() {
|
||||
const theme = THEMES[currentTheme];
|
||||
const data = currentThemeData;
|
||||
const colors = COLOR_MAP[theme.color];
|
||||
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
|
||||
<div class="theme-title">${theme.title}</div>
|
||||
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${theme.items.map(label => {
|
||||
const value = data.answers[label] || "";
|
||||
const empty = isEmpty(value);
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(label)}</div>
|
||||
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentTheme === 0;
|
||||
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= THEMES.length) return;
|
||||
currentTheme = idx;
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentThemeData = data;
|
||||
currentTheme = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
if (data.meta.participants) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(data.meta.participants)}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("idCard").innerHTML = `
|
||||
<div><div class="label">Groupe / Stagiaire</div><div class="value">${escapeHtml(data.meta.nom) || "—"}</div></div>
|
||||
<div><div class="label">Date de session</div><div class="value">${escapeHtml(data.meta.date_session) || "—"}</div></div>
|
||||
<div><div class="label">Formateur</div><div class="value">${escapeHtml(data.meta.formateur) || "—"}</div></div>
|
||||
<div><div class="label">Cas pratique</div><div class="value">${escapeHtml(data.meta.cas) || "—"}</div></div>
|
||||
`;
|
||||
|
||||
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
|
||||
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
|
||||
|
||||
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderThemePanel();
|
||||
|
||||
document.getElementById("tp3Meta").innerHTML = `
|
||||
<p style="margin-bottom:6px;"><strong>Intitulé :</strong> ${escapeHtml(data.tp3.intitule) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p style="margin-bottom:6px;"><strong>Description :</strong> ${escapeHtml(data.tp3.description) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p style="margin-bottom:6px;"><strong>Lieu :</strong> ${escapeHtml(data.tp3.lieu) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p><strong>Début prévu le :</strong> ${escapeHtml(data.tp3.debut) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
`;
|
||||
|
||||
const tp3TableEl = document.getElementById("tp3Table");
|
||||
if (data.riskRows && data.riskRows.length > 0) {
|
||||
tp3TableEl.style.display = "block";
|
||||
tp3TableEl.innerHTML = `
|
||||
<table class="risk-table">
|
||||
<thead><tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr></thead>
|
||||
<tbody>
|
||||
${data.riskRows.map(r => `
|
||||
<tr>
|
||||
<td>${escapeHtml(r.etape) || "—"}</td>
|
||||
<td>${escapeHtml(r.risque) || "—"}</td>
|
||||
<td>${escapeHtml(r.mesure) || "—"}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
tp3TableEl.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("suiviLine").innerHTML =
|
||||
`Réalisée par : <strong>${escapeHtml(data.suivi.realise) || "—"}</strong> — Vérifiée par : <strong>${escapeHtml(data.suivi.verifie) || "—"}</strong> — Mise à jour par : <strong>${escapeHtml(data.suivi.maj) || "—"}</strong>`;
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TP.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
document.getElementById("tp3Table").style.display = "none";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1204
exercices/corriger-exercice.html
Normal file
1204
exercices/corriger-exercice.html
Normal file
File diff suppressed because it is too large
Load Diff
55
exercices/memory-pictogrammes/api/README.md
Normal file
55
exercices/memory-pictogrammes/api/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# API du classement Memory (ASP.NET + SQL Server)
|
||||
|
||||
Petit service **ASP.NET (C#) + SQL Server** qui héberge en ligne le
|
||||
**classement partagé** du jeu Memory. Tous les joueurs enregistrent leurs
|
||||
scores au même endroit et voient le même top 10 par mode (facile / normal /
|
||||
difficile).
|
||||
|
||||
> Adapté à l'hébergement **FreeASPHosting.net / bsite.net** (Windows, IIS,
|
||||
> base MSSQL 2016). Le fichier `scores.ashx` est compilé au vol par IIS :
|
||||
> **aucune étape de build**, il suffit de le déposer.
|
||||
|
||||
## Contenu
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `scores.ashx` | Point d'entrée de l'API (GET pour lire, POST pour enregistrer). Crée la table automatiquement au premier appel. |
|
||||
| `web.config.example` | Modèle de configuration à copier en `web.config`. |
|
||||
| `web.config` | **À créer sur le serveur** avec le mot de passe SQL Server (clé `MemoryDb`). Non versionné. |
|
||||
| `README.md` | Ce fichier. |
|
||||
|
||||
## Installation sur bsite.net
|
||||
|
||||
1. **Récupérer le mot de passe** de la base dans le panneau
|
||||
*Manage Databases*. La base existe déjà :
|
||||
- Serveur : `sql.bsite.net\MSSQL2016`
|
||||
- Base / utilisateur : `nextgnformation_reponses_exercices`
|
||||
|
||||
2. **Créer `web.config`** à partir du modèle (`cp web.config.example web.config`),
|
||||
puis remplacer `VOTRE_MOT_DE_PASSE` par le mot de passe réel dans la clé
|
||||
`MemoryDb`.
|
||||
|
||||
3. **Déposer le dossier `api/`** via *My Files*, au même endroit que
|
||||
`memory.html` (chemin attendu par le jeu :
|
||||
`exercices/memory-pictogrammes/api/scores.ashx`).
|
||||
|
||||
4. C'est prêt : au premier score enregistré (ou premier appel GET), la table
|
||||
`memory_scores` est créée automatiquement.
|
||||
|
||||
## Vérification rapide
|
||||
|
||||
- Lecture du classement :
|
||||
`GET https://nextgnformation.bsite.net/exercices/memory-pictogrammes/api/scores.ashx?difficulty=all`
|
||||
doit renvoyer `{"ok":true,"scores":[...]}`.
|
||||
|
||||
- Seuls les 10 meilleurs de chaque mode sont conservés : après chaque
|
||||
enregistrement, les scores hors top 10 du mode concerné sont supprimés.
|
||||
|
||||
## Points d'attention
|
||||
|
||||
- **Ne poussez jamais `web.config` avec le vrai mot de passe dans un dépôt
|
||||
public.** Éditez-le directement sur le serveur via *My Files*.
|
||||
- Les statistiques personnelles (« Vos statistiques ») restent stockées
|
||||
localement dans le navigateur de chaque joueur ; seul le classement est en ligne.
|
||||
- Le score étant calculé côté navigateur, l'API valide les types et les bornes
|
||||
mais ne garantit pas l'absence de triche (recalcul serveur possible plus tard).
|
||||
329
exercices/memory-pictogrammes/api/scores.ashx
Normal file
329
exercices/memory-pictogrammes/api/scores.ashx
Normal file
@@ -0,0 +1,329 @@
|
||||
<%@ WebHandler Language="C#" Class="ScoresHandler" %>
|
||||
/*
|
||||
* API du classement Memory (pictogrammes de sécurité) — ASP.NET + SQL Server.
|
||||
*
|
||||
* GET scores.ashx?difficulty=easy|normal|hard|all[&limit=10]
|
||||
* -> renvoie le classement (top N) trié par score décroissant.
|
||||
*
|
||||
* POST scores.ashx (corps JSON)
|
||||
* { playerName, userId, score, moves, timeSeconds, difficulty, isPerfect }
|
||||
* -> enregistre le score, ne conserve que le top N par difficulté,
|
||||
* renvoie { ok, saved, rank, isTop }.
|
||||
*
|
||||
* Seul le top N (défaut 10) de chaque mode est conservé : les scores plus
|
||||
* faibles sont supprimés après chaque insertion.
|
||||
*
|
||||
* Fichier compilé au vol par IIS : il suffit de le déposer, aucune build.
|
||||
* La chaîne de connexion est lue dans web.config (clé "MemoryDb").
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using System.Web.Configuration;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
public class ScoresHandler : IHttpHandler
|
||||
{
|
||||
static readonly string[] Difficulties = { "easy", "normal", "hard" };
|
||||
const int TopN = 10;
|
||||
|
||||
public bool IsReusable { get { return false; } }
|
||||
|
||||
public void ProcessRequest(HttpContext ctx)
|
||||
{
|
||||
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||
ctx.Response.AddHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
var connString = WebConfigurationManager.ConnectionStrings["MemoryDb"] != null
|
||||
? WebConfigurationManager.ConnectionStrings["MemoryDb"].ConnectionString
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrEmpty(connString))
|
||||
{
|
||||
Fail(ctx, 500, "Configuration manquante (connectionString 'MemoryDb' dans web.config).");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var conn = new SqlConnection(connString))
|
||||
{
|
||||
conn.Open();
|
||||
EnsureTable(conn);
|
||||
|
||||
if (ctx.Request.HttpMethod == "GET")
|
||||
{
|
||||
HandleGet(ctx, conn);
|
||||
}
|
||||
else if (ctx.Request.HttpMethod == "POST")
|
||||
{
|
||||
HandlePost(ctx, conn);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.Response.StatusCode = 405;
|
||||
ctx.Response.AddHeader("Allow", "GET, POST");
|
||||
Write(ctx, new Dictionary<string, object> {
|
||||
{ "ok", false }, { "error", "Méthode non autorisée." }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Fail(ctx, 500, "Erreur serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Création de la table si besoin -------------------------------------
|
||||
void EnsureTable(SqlConnection conn)
|
||||
{
|
||||
const string sql =
|
||||
"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'memory_scores') " +
|
||||
"CREATE TABLE memory_scores (" +
|
||||
" id INT IDENTITY(1,1) PRIMARY KEY," +
|
||||
" player_name NVARCHAR(40) NOT NULL," +
|
||||
" user_id NVARCHAR(64) NOT NULL," +
|
||||
" score INT NOT NULL," +
|
||||
" moves INT NOT NULL," +
|
||||
" time_seconds INT NOT NULL," +
|
||||
" difficulty NVARCHAR(16) NOT NULL," +
|
||||
" is_perfect BIT NOT NULL DEFAULT 0," +
|
||||
" created_at DATETIME NOT NULL" +
|
||||
");";
|
||||
using (var cmd = new SqlCommand(sql, conn)) { cmd.ExecuteNonQuery(); }
|
||||
}
|
||||
|
||||
// --- GET : renvoie le classement ----------------------------------------
|
||||
void HandleGet(HttpContext ctx, SqlConnection conn)
|
||||
{
|
||||
var difficulty = (ctx.Request.QueryString["difficulty"] ?? "all").Trim();
|
||||
int limit;
|
||||
if (!int.TryParse(ctx.Request.QueryString["limit"], out limit)) limit = TopN;
|
||||
if (limit < 1) limit = 1;
|
||||
if (limit > TopN) limit = TopN;
|
||||
|
||||
var rows = new List<Dictionary<string, object>>();
|
||||
|
||||
if (difficulty == "all")
|
||||
{
|
||||
foreach (var diff in Difficulties)
|
||||
rows.AddRange(FetchTop(conn, diff, TopN));
|
||||
|
||||
rows.Sort(CompareRows);
|
||||
if (rows.Count > limit) rows = rows.GetRange(0, limit);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Array.IndexOf(Difficulties, difficulty) < 0)
|
||||
{
|
||||
Fail(ctx, 400, "Difficulté inconnue.");
|
||||
return;
|
||||
}
|
||||
rows = FetchTop(conn, difficulty, limit);
|
||||
}
|
||||
|
||||
Write(ctx, new Dictionary<string, object> { { "ok", true }, { "scores", rows } });
|
||||
}
|
||||
|
||||
// --- POST : enregistre un score puis élague au top N --------------------
|
||||
void HandlePost(HttpContext ctx, SqlConnection conn)
|
||||
{
|
||||
string body;
|
||||
using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
|
||||
body = reader.ReadToEnd();
|
||||
|
||||
Dictionary<string, object> data = null;
|
||||
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(body); }
|
||||
catch { data = null; }
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
Fail(ctx, 400, "Corps JSON invalide.");
|
||||
return;
|
||||
}
|
||||
|
||||
var difficulty = Str(data, "difficulty");
|
||||
if (Array.IndexOf(Difficulties, difficulty) < 0)
|
||||
{
|
||||
Fail(ctx, 400, "Difficulté inconnue.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Nettoyage / bornage des entrées
|
||||
var playerName = Str(data, "playerName").Trim();
|
||||
playerName = StripControlChars(playerName);
|
||||
if (playerName.Length == 0) playerName = "Anonyme";
|
||||
if (playerName.Length > 40) playerName = playerName.Substring(0, 40);
|
||||
|
||||
var userId = OnlyIdChars(Str(data, "userId"));
|
||||
if (userId.Length > 64) userId = userId.Substring(0, 64);
|
||||
if (userId.Length == 0) userId = "anon";
|
||||
|
||||
int score = Clamp(Int(data, "score"), 0, 100000000);
|
||||
int moves = Clamp(Int(data, "moves"), 0, 100000);
|
||||
int time = Clamp(Int(data, "timeSeconds"), 0, 1000000);
|
||||
int isPerfect = Bool(data, "isPerfect") ? 1 : 0;
|
||||
|
||||
int insertedId;
|
||||
using (var cmd = new SqlCommand(
|
||||
"INSERT INTO memory_scores " +
|
||||
"(player_name, user_id, score, moves, time_seconds, difficulty, is_perfect, created_at) " +
|
||||
"OUTPUT INSERTED.id " +
|
||||
"VALUES (@name, @uid, @score, @moves, @time, @diff, @perfect, GETUTCDATE())", conn))
|
||||
{
|
||||
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 40).Value = playerName;
|
||||
cmd.Parameters.Add("@uid", SqlDbType.NVarChar, 64).Value = userId;
|
||||
cmd.Parameters.Add("@score", SqlDbType.Int).Value = score;
|
||||
cmd.Parameters.Add("@moves", SqlDbType.Int).Value = moves;
|
||||
cmd.Parameters.Add("@time", SqlDbType.Int).Value = time;
|
||||
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
|
||||
cmd.Parameters.Add("@perfect", SqlDbType.Bit).Value = isPerfect;
|
||||
insertedId = (int)cmd.ExecuteScalar();
|
||||
}
|
||||
|
||||
// Ne conserver que le top N pour ce mode
|
||||
using (var cmd = new SqlCommand(
|
||||
"WITH ranked AS (" +
|
||||
" SELECT id, ROW_NUMBER() OVER (" +
|
||||
" PARTITION BY difficulty ORDER BY score DESC, time_seconds ASC, id ASC) AS rn" +
|
||||
" FROM memory_scores WHERE difficulty = @diff" +
|
||||
") DELETE FROM memory_scores WHERE id IN (SELECT id FROM ranked WHERE rn > @topN)", conn))
|
||||
{
|
||||
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
|
||||
cmd.Parameters.Add("@topN", SqlDbType.Int).Value = TopN;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Le score inséré a-t-il survécu à l'élagage ? Quel rang ?
|
||||
var top = FetchTop(conn, difficulty, TopN);
|
||||
int? rank = null;
|
||||
for (int i = 0; i < top.Count; i++)
|
||||
{
|
||||
if ((int)top[i]["id"] == insertedId) { rank = i + 1; break; }
|
||||
}
|
||||
|
||||
Write(ctx, new Dictionary<string, object> {
|
||||
{ "ok", true },
|
||||
{ "saved", true },
|
||||
{ "isTop", rank.HasValue },
|
||||
{ "rank", rank.HasValue ? (object)rank.Value : null }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Accès données -------------------------------------------------------
|
||||
List<Dictionary<string, object>> FetchTop(SqlConnection conn, string difficulty, int limit)
|
||||
{
|
||||
var result = new List<Dictionary<string, object>>();
|
||||
using (var cmd = new SqlCommand(
|
||||
"SELECT TOP (@limit) id, player_name, user_id, score, moves, time_seconds, " +
|
||||
"difficulty, is_perfect, created_at " +
|
||||
"FROM memory_scores WHERE difficulty = @diff " +
|
||||
"ORDER BY score DESC, time_seconds ASC, id ASC", conn))
|
||||
{
|
||||
cmd.Parameters.Add("@limit", SqlDbType.Int).Value = limit;
|
||||
cmd.Parameters.Add("@diff", SqlDbType.NVarChar, 16).Value = difficulty;
|
||||
using (var r = cmd.ExecuteReader())
|
||||
{
|
||||
while (r.Read())
|
||||
{
|
||||
result.Add(new Dictionary<string, object> {
|
||||
{ "id", (int)r["id"] },
|
||||
{ "playerName", r["player_name"].ToString() },
|
||||
{ "userId", r["user_id"].ToString() },
|
||||
{ "score", (int)r["score"] },
|
||||
{ "moves", (int)r["moves"] },
|
||||
{ "timeSeconds", (int)r["time_seconds"] },
|
||||
{ "difficulty", r["difficulty"].ToString() },
|
||||
{ "isPerfect", Convert.ToBoolean(r["is_perfect"]) },
|
||||
{ "date", ((DateTime)r["created_at"]).ToString("yyyy-MM-ddTHH:mm:ss") + "Z" }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int CompareRows(Dictionary<string, object> a, Dictionary<string, object> b)
|
||||
{
|
||||
int sa = (int)a["score"], sb = (int)b["score"];
|
||||
if (sa != sb) return sb - sa;
|
||||
return (int)a["timeSeconds"] - (int)b["timeSeconds"];
|
||||
}
|
||||
|
||||
// --- Sérialisation / helpers --------------------------------------------
|
||||
void Write(HttpContext ctx, object payload)
|
||||
{
|
||||
// On retire la clé "id" (interne) des lignes avant l'envoi.
|
||||
var scrubbed = Scrub(payload);
|
||||
ctx.Response.Write(new JavaScriptSerializer().Serialize(scrubbed));
|
||||
}
|
||||
|
||||
object Scrub(object payload)
|
||||
{
|
||||
var dict = payload as Dictionary<string, object>;
|
||||
if (dict != null && dict.ContainsKey("scores"))
|
||||
{
|
||||
var list = dict["scores"] as List<Dictionary<string, object>>;
|
||||
if (list != null)
|
||||
foreach (var row in list) row.Remove("id");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
void Fail(HttpContext ctx, int status, string message)
|
||||
{
|
||||
ctx.Response.StatusCode = status;
|
||||
ctx.Response.Write(new JavaScriptSerializer().Serialize(
|
||||
new Dictionary<string, object> { { "ok", false }, { "error", message } }));
|
||||
}
|
||||
|
||||
static string Str(Dictionary<string, object> d, string key)
|
||||
{
|
||||
object v; return (d.TryGetValue(key, out v) && v != null) ? v.ToString() : "";
|
||||
}
|
||||
|
||||
static int Int(Dictionary<string, object> d, string key)
|
||||
{
|
||||
object v; int n;
|
||||
if (d.TryGetValue(key, out v) && v != null && int.TryParse(v.ToString(), out n)) return n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool Bool(Dictionary<string, object> d, string key)
|
||||
{
|
||||
object v;
|
||||
if (!d.TryGetValue(key, out v) || v == null) return false;
|
||||
if (v is bool) return (bool)v;
|
||||
var s = v.ToString().ToLowerInvariant();
|
||||
return s == "true" || s == "1";
|
||||
}
|
||||
|
||||
static int Clamp(int value, int min, int max)
|
||||
{
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
static string StripControlChars(string s)
|
||||
{
|
||||
var sb = new StringBuilder(s.Length);
|
||||
foreach (var c in s) if (!char.IsControl(c)) sb.Append(c);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static string OnlyIdChars(string s)
|
||||
{
|
||||
var sb = new StringBuilder(s.Length);
|
||||
foreach (var c in s)
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')
|
||||
sb.Append(c);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
16
exercices/memory-pictogrammes/api/web.config.example
Normal file
16
exercices/memory-pictogrammes/api/web.config.example
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<!--
|
||||
MODÈLE de configuration. Sur le serveur bsite.net :
|
||||
1. Copiez ce fichier en « web.config ».
|
||||
2. Renseignez le mot de passe fourni par le panneau « Manage Databases ».
|
||||
|
||||
Le vrai « web.config » n'est PAS versionné (voir .gitignore) afin de ne
|
||||
jamais exposer le mot de passe dans l'historique Git.
|
||||
-->
|
||||
<connectionStrings>
|
||||
<add name="MemoryDb"
|
||||
connectionString="Server=sql.bsite.net\MSSQL2016;Database=nextgnformation_reponses_exercices;User Id=nextgnformation_reponses_exercices;Password=VOTRE_MOT_DE_PASSE;"
|
||||
providerName="System.Data.SqlClient" />
|
||||
</connectionStrings>
|
||||
</configuration>
|
||||
@@ -93,6 +93,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ligne du joueur courant dans le classement */
|
||||
.rank-item.is-me {
|
||||
border-left-color: var(--amber-dark);
|
||||
background: linear-gradient(135deg, var(--amber-light), var(--white));
|
||||
box-shadow: 0 2px 8px rgba(186, 117, 23, 0.15);
|
||||
}
|
||||
|
||||
.rank-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,90 +1,114 @@
|
||||
/* ============================================
|
||||
LEADERBOARD & PLAYER STATISTICS
|
||||
============================================
|
||||
Le CLASSEMENT est désormais partagé EN LIGNE : tous les joueurs
|
||||
enregistrent leurs scores via l'API PHP (api/scores.php) et voient le
|
||||
même top 10 par mode de jeu.
|
||||
|
||||
Les STATISTIQUES PERSONNELLES (« Vos statistiques ») restent stockées
|
||||
localement dans le navigateur du joueur, car le serveur ne conserve que
|
||||
le top 10 de chaque mode (et non l'historique complet de chacun).
|
||||
============================================ */
|
||||
|
||||
/**
|
||||
* Manager du classement
|
||||
*/
|
||||
class LeaderboardManager {
|
||||
static KEY_LEADERBOARD = 'nextgn_leaderboard';
|
||||
static MAX_SCORES = 100;
|
||||
|
||||
// Point d'entrée de l'API (relatif à memory.html) — ASP.NET + SQL Server
|
||||
static API_URL = 'api/scores.ashx';
|
||||
// Historique local des parties du joueur (pour ses stats perso)
|
||||
static KEY_HISTORY = 'nextgn_player_history';
|
||||
static MAX_HISTORY = 500;
|
||||
|
||||
// Cache mémoire du dernier classement récupéré (par difficulté)
|
||||
static _cache = {};
|
||||
|
||||
/**
|
||||
* Ajouter un score au classement
|
||||
* Enregistrer une partie :
|
||||
* - envoie le score au classement en ligne
|
||||
* - conserve la partie dans l'historique local (stats perso)
|
||||
* @returns {Promise<Object>} résultat de l'API ({ ok, isTop, rank } ou { ok:false })
|
||||
*/
|
||||
static addScore(gameData) {
|
||||
const scores = this.getAllScores();
|
||||
|
||||
const newScore = {
|
||||
sessionId: generateUUID(),
|
||||
static async addScore(gameData) {
|
||||
const record = {
|
||||
playerName: getPlayerName(),
|
||||
userId: getUserId(),
|
||||
score: gameData.score,
|
||||
moves: gameData.moves,
|
||||
timeSeconds: gameData.duration,
|
||||
difficulty: gameData.difficulty,
|
||||
date: new Date().toISOString(),
|
||||
isPerfect: gameData.moves === DIFFICULTY_MODES[gameData.difficulty].paires,
|
||||
won: gameData.won !== false
|
||||
won: gameData.won !== false,
|
||||
date: new Date().toISOString()
|
||||
};
|
||||
|
||||
scores.push(newScore);
|
||||
|
||||
// Limiter à MAX_SCORES (garder les plus récents)
|
||||
if (scores.length > this.MAX_SCORES) {
|
||||
scores.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
scores.splice(this.MAX_SCORES);
|
||||
}
|
||||
|
||||
localStorage.setItem(this.KEY_LEADERBOARD, JSON.stringify(scores));
|
||||
|
||||
DEBUG.log('Score added:', newScore);
|
||||
|
||||
return newScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir tous les scores
|
||||
*/
|
||||
static getAllScores() {
|
||||
|
||||
// 1) Historique local (statistiques personnelles)
|
||||
this._recordLocalHistory(record);
|
||||
|
||||
// 2) Classement en ligne
|
||||
try {
|
||||
const scores = JSON.parse(localStorage.getItem(this.KEY_LEADERBOARD)) || [];
|
||||
return scores;
|
||||
const res = await fetch(this.API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(record)
|
||||
});
|
||||
const data = await res.json();
|
||||
DEBUG.log('Score submitted:', data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('Leaderboard submit error:', e);
|
||||
return { ok: false, error: 'network' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter la partie à l'historique local du joueur
|
||||
*/
|
||||
static _recordLocalHistory(record) {
|
||||
const history = this._getLocalHistory();
|
||||
history.push(record);
|
||||
if (history.length > this.MAX_HISTORY) {
|
||||
history.splice(0, history.length - this.MAX_HISTORY);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(this.KEY_HISTORY, JSON.stringify(history));
|
||||
} catch (e) {
|
||||
console.error('History save error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Historique local des parties du joueur
|
||||
*/
|
||||
static _getLocalHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(this.KEY_HISTORY)) || [];
|
||||
} catch (e) {
|
||||
console.error('Leaderboard error:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Obtenir le classement (top X)
|
||||
* Récupérer le classement en ligne (top X) pour un mode donné.
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
static getLeaderboard(difficulty = 'all', limit = 10) {
|
||||
let scores = this.getAllScores();
|
||||
|
||||
// Filtrer par difficulté
|
||||
if (difficulty !== 'all') {
|
||||
scores = scores.filter(s => s.difficulty === difficulty);
|
||||
static async getLeaderboard(difficulty = 'all', limit = 10) {
|
||||
const url = `${this.API_URL}?difficulty=${encodeURIComponent(difficulty)}&limit=${limit}`;
|
||||
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||
const data = await res.json();
|
||||
if (!data || data.ok !== true || !Array.isArray(data.scores)) {
|
||||
throw new Error('Réponse invalide du serveur');
|
||||
}
|
||||
|
||||
// Trier par score (décroissant)
|
||||
scores.sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
// En cas d'égalité, par temps
|
||||
return a.timeSeconds - b.timeSeconds;
|
||||
});
|
||||
|
||||
return scores.slice(0, limit);
|
||||
this._cache[difficulty] = data.scores;
|
||||
return data.scores;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Obtenir les stats d'un joueur
|
||||
* Statistiques du joueur, calculées à partir de son historique LOCAL.
|
||||
*/
|
||||
static getPlayerStats(userId) {
|
||||
const scores = this.getAllScores().filter(s => s.userId === userId);
|
||||
|
||||
static getPlayerStats() {
|
||||
const scores = this._getLocalHistory();
|
||||
|
||||
if (scores.length === 0) {
|
||||
return {
|
||||
totalGames: 0,
|
||||
@@ -96,14 +120,14 @@ class LeaderboardManager {
|
||||
perfectGames: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const bestScore = Math.max(...scores.map(s => s.score), 0);
|
||||
const totalScore = scores.reduce((a, b) => a + b.score, 0);
|
||||
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
|
||||
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
|
||||
const perfectGames = scores.filter(s => s.isPerfect).length;
|
||||
const winRate = (perfectGames / scores.length) * 100;
|
||||
|
||||
|
||||
return {
|
||||
totalGames: scores.length,
|
||||
bestScore: bestScore,
|
||||
@@ -114,63 +138,37 @@ class LeaderboardManager {
|
||||
perfectGames: perfectGames
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Obtenir le rang d'un score
|
||||
* Mettre à jour l'affichage du classement (asynchrone).
|
||||
*/
|
||||
static getPlayerRank(userId, difficulty = 'all') {
|
||||
const leaderboard = this.getLeaderboard(difficulty, Infinity);
|
||||
const rank = leaderboard.findIndex(s => s.userId === userId) + 1;
|
||||
return rank > 0 ? rank : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si c'est un nouveau record personnel
|
||||
*/
|
||||
static isPersonalRecord(userId, newScore) {
|
||||
const stats = this.getPlayerStats(userId);
|
||||
return newScore > stats.bestScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques globales
|
||||
*/
|
||||
static getGlobalStats() {
|
||||
const scores = this.getAllScores();
|
||||
|
||||
if (scores.length === 0) {
|
||||
return {
|
||||
totalGames: 0,
|
||||
totalPlayers: 0,
|
||||
averageScore: 0,
|
||||
highestScore: 0,
|
||||
fastestCompletion: Infinity
|
||||
};
|
||||
}
|
||||
|
||||
const uniquePlayers = new Set(scores.map(s => s.userId));
|
||||
const totalScore = scores.reduce((a, b) => a + b.score, 0);
|
||||
const highestScore = Math.max(...scores.map(s => s.score), 0);
|
||||
const fastestCompletion = Math.min(...scores.map(s => s.timeSeconds), Infinity);
|
||||
|
||||
return {
|
||||
totalGames: scores.length,
|
||||
totalPlayers: uniquePlayers.size,
|
||||
averageScore: Math.round(totalScore / scores.length),
|
||||
highestScore: highestScore,
|
||||
fastestCompletion: fastestCompletion === Infinity ? 0 : fastestCompletion
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour l'affichage du classement
|
||||
*/
|
||||
static updateDisplay(difficulty = 'all') {
|
||||
const leaderboard = this.getLeaderboard(difficulty, 10);
|
||||
static async updateDisplay(difficulty = 'all') {
|
||||
const leaderboardList = document.getElementById('leaderboard-list');
|
||||
|
||||
if (!leaderboardList) return;
|
||||
|
||||
|
||||
// État de chargement
|
||||
leaderboardList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">⏳</div>
|
||||
<div class="empty-state-text">Chargement du classement…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let leaderboard;
|
||||
try {
|
||||
leaderboard = await this.getLeaderboard(difficulty, 10);
|
||||
} catch (e) {
|
||||
console.error('Leaderboard load error:', e);
|
||||
leaderboardList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">⚠️</div>
|
||||
<div class="empty-state-text">Classement indisponible</div>
|
||||
<div class="empty-state-hint">Vérifiez votre connexion et réessayez.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderboard.length === 0) {
|
||||
leaderboardList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
@@ -181,16 +179,20 @@ class LeaderboardManager {
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const myUserId = getUserId();
|
||||
leaderboardList.innerHTML = '';
|
||||
|
||||
|
||||
leaderboard.forEach((score, index) => {
|
||||
const rankItem = document.createElement('div');
|
||||
rankItem.className = 'rank-item';
|
||||
|
||||
if (score.userId === myUserId) {
|
||||
rankItem.classList.add('is-me');
|
||||
}
|
||||
|
||||
const rankNumber = this.getRankDisplay(index);
|
||||
const medal = index < 3 ? `class="rank-number medal-${index + 1}"` : 'class="rank-number"';
|
||||
|
||||
|
||||
rankItem.innerHTML = `
|
||||
<div class="rank-top">
|
||||
<div ${medal}>${rankNumber}</div>
|
||||
@@ -205,96 +207,53 @@ class LeaderboardManager {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
leaderboardList.appendChild(rankItem);
|
||||
});
|
||||
|
||||
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
|
||||
|
||||
if (typeof analyticsManager !== 'undefined') {
|
||||
analyticsManager.track('leaderboard_viewed', { filter: difficulty });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mettre à jour les statistiques du joueur
|
||||
* Mettre à jour les statistiques du joueur (locales).
|
||||
*/
|
||||
static updatePlayerStats() {
|
||||
const userId = getUserId();
|
||||
const stats = this.getPlayerStats(userId);
|
||||
|
||||
// Remplir les éléments HTML
|
||||
document.getElementById('total-games').textContent = stats.totalGames;
|
||||
document.getElementById('best-score').textContent = formatNumber(stats.bestScore);
|
||||
document.getElementById('best-time').textContent = stats.bestTime === Infinity ? '--' : formatTime(stats.bestTime);
|
||||
document.getElementById('win-rate').textContent = `${stats.winRate}%`;
|
||||
|
||||
// Ajouter des classes pour animation si valeurs élevées
|
||||
if (stats.bestScore > 5000) {
|
||||
document.getElementById('best-score').parentElement.classList.add('high-value');
|
||||
const stats = this.getPlayerStats();
|
||||
|
||||
const totalGamesEl = document.getElementById('total-games');
|
||||
const bestScoreEl = document.getElementById('best-score');
|
||||
const bestTimeEl = document.getElementById('best-time');
|
||||
const winRateEl = document.getElementById('win-rate');
|
||||
|
||||
if (totalGamesEl) totalGamesEl.textContent = stats.totalGames;
|
||||
if (bestScoreEl) bestScoreEl.textContent = formatNumber(stats.bestScore);
|
||||
if (bestTimeEl) bestTimeEl.textContent = stats.bestTime === Infinity || stats.bestTime === 0 ? '--' : formatTime(stats.bestTime);
|
||||
if (winRateEl) winRateEl.textContent = `${stats.winRate}%`;
|
||||
|
||||
if (bestScoreEl && stats.bestScore > 5000) {
|
||||
bestScoreEl.parentElement.classList.add('high-value');
|
||||
}
|
||||
|
||||
if (stats.winRate >= 50) {
|
||||
document.getElementById('win-rate').parentElement.classList.add('high-value');
|
||||
if (winRateEl && stats.winRate >= 50) {
|
||||
winRateEl.parentElement.classList.add('high-value');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Afficher le rang du joueur
|
||||
* Effacer l'historique local du joueur (DEBUG).
|
||||
* N'affecte PAS le classement en ligne partagé.
|
||||
*/
|
||||
static updatePlayerRank(difficulty = 'all') {
|
||||
const userId = getUserId();
|
||||
const rank = this.getPlayerRank(userId, difficulty);
|
||||
|
||||
if (rank) {
|
||||
const rankDisplay = document.getElementById('player-rank');
|
||||
if (rankDisplay) {
|
||||
rankDisplay.textContent = `Vous êtes #{rank}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Afficher les statistiques globales
|
||||
*/
|
||||
static displayGlobalStats() {
|
||||
const globalStats = this.getGlobalStats();
|
||||
DEBUG.log('Global Statistics:', globalStats);
|
||||
|
||||
return globalStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les scores (DEBUG)
|
||||
*/
|
||||
static clearAll() {
|
||||
if (confirm('Êtes-vous ABSOLUMENT certain de vouloir supprimer TOUS les scores?')) {
|
||||
localStorage.removeItem(this.KEY_LEADERBOARD);
|
||||
this.updateDisplay();
|
||||
static clearLocalHistory() {
|
||||
if (confirm('Effacer VOS statistiques locales ? (le classement en ligne n\'est pas touché)')) {
|
||||
localStorage.removeItem(this.KEY_HISTORY);
|
||||
this.updatePlayerStats();
|
||||
DEBUG.log('✓ Tous les scores ont été supprimés');
|
||||
DEBUG.log('✓ Historique local effacé');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporter les scores en CSV
|
||||
*/
|
||||
static exportCSV() {
|
||||
const scores = this.getAllScores();
|
||||
|
||||
let csv = 'Rang,Joueur,Score,Difficulté,Temps (s),Mouvements,Parfait,Date\n';
|
||||
|
||||
scores.forEach((score, index) => {
|
||||
csv += `${index + 1},"${score.playerName}",${score.score},"${score.difficulty}",${score.timeSeconds},${score.moves},${score.isPerfect ? 'Oui' : 'Non'},"${score.date}"\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `leaderboard-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Obtenir l'affichage du rang (numéro ou médaille)
|
||||
*/
|
||||
@@ -302,7 +261,7 @@ class LeaderboardManager {
|
||||
const medals = ['🥇', '🥈', '🥉'];
|
||||
return medals[index] || `${index + 1}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Échapper le HTML (sécurité)
|
||||
*/
|
||||
@@ -313,78 +272,11 @@ class LeaderboardManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiques avancées
|
||||
*/
|
||||
class AdvancedStats {
|
||||
/**
|
||||
* Obtenir la distribution des scores par difficulté
|
||||
*/
|
||||
static getDistributionByDifficulty() {
|
||||
const scores = LeaderboardManager.getAllScores();
|
||||
const distribution = {};
|
||||
|
||||
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
|
||||
distribution[difficulty] = scores.filter(s => s.difficulty === difficulty).length;
|
||||
});
|
||||
|
||||
return distribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la tendance temporelle
|
||||
*/
|
||||
static getTimelineTrend(days = 7) {
|
||||
const scores = LeaderboardManager.getAllScores();
|
||||
const now = new Date();
|
||||
const timeline = {};
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().slice(0, 10);
|
||||
timeline[dateStr] = scores.filter(s => s.date.slice(0, 10) === dateStr).length;
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la moyenne de temps par difficulté
|
||||
*/
|
||||
static getAverageTimeByDifficulty() {
|
||||
const scores = LeaderboardManager.getAllScores();
|
||||
const averages = {};
|
||||
|
||||
Object.keys(DIFFICULTY_MODES).forEach(difficulty => {
|
||||
const diffScores = scores.filter(s => s.difficulty === difficulty);
|
||||
if (diffScores.length > 0) {
|
||||
const totalTime = diffScores.reduce((a, b) => a + b.timeSeconds, 0);
|
||||
averages[difficulty] = Math.round(totalTime / diffScores.length);
|
||||
}
|
||||
});
|
||||
|
||||
return averages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Taux de réussite global
|
||||
*/
|
||||
static getGlobalSuccessRate() {
|
||||
const scores = LeaderboardManager.getAllScores();
|
||||
if (scores.length === 0) return 0;
|
||||
|
||||
const perfectGames = scores.filter(s => s.isPerfect).length;
|
||||
return Math.round((perfectGames / scores.length) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION
|
||||
// ============================================
|
||||
|
||||
// Exporter pour utilisation globale
|
||||
window.LeaderboardManager = LeaderboardManager;
|
||||
window.AdvancedStats = AdvancedStats;
|
||||
|
||||
DEBUG.log('Leaderboard Manager initialized');
|
||||
DEBUG.log('Leaderboard Manager initialized (online mode)');
|
||||
|
||||
@@ -347,12 +347,16 @@ class MemoryGameEngine {
|
||||
achievements: achievements.map(a => a.id)
|
||||
});
|
||||
|
||||
// Sauvegarder score
|
||||
// Sauvegarder le score (classement en ligne) puis rafraîchir l'affichage
|
||||
LeaderboardManager.addScore({
|
||||
...gameData,
|
||||
score: finalScore.totalScore
|
||||
}).then(() => {
|
||||
const activeFilter = document.querySelector('.tab-btn.active')?.getAttribute('data-filter') || 'all';
|
||||
LeaderboardManager.updateDisplay(activeFilter);
|
||||
LeaderboardManager.updatePlayerStats();
|
||||
});
|
||||
|
||||
|
||||
// Afficher modal victoire
|
||||
this.showVictoryModal(finalScore, achievements, isWon);
|
||||
}
|
||||
|
||||
@@ -292,10 +292,10 @@ function getRank(index) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si le score est dans le top 10
|
||||
* Vérifier si le score est dans le top 10 (classement en ligne, asynchrone)
|
||||
*/
|
||||
function isTopScore(score, difficulty = 'all') {
|
||||
const scores = LeaderboardManager.getLeaderboard(difficulty, 10);
|
||||
async function isTopScore(score, difficulty = 'all') {
|
||||
const scores = await LeaderboardManager.getLeaderboard(difficulty, 10);
|
||||
return scores.some(s => s.score === score.score && s.userId === score.userId);
|
||||
}
|
||||
|
||||
@@ -318,57 +318,25 @@ function formatDate(isoDate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les statistiques d'un joueur
|
||||
* Obtenir les statistiques locales du joueur
|
||||
*/
|
||||
function getPlayerStatistics(userId) {
|
||||
const scores = LeaderboardManager.getAllScores().filter(s => s.userId === userId);
|
||||
|
||||
if (scores.length === 0) {
|
||||
return {
|
||||
totalGames: 0,
|
||||
bestScore: 0,
|
||||
averageScore: 0,
|
||||
averageTime: 0,
|
||||
bestTime: Infinity,
|
||||
winRate: 0,
|
||||
perfectGames: 0
|
||||
};
|
||||
}
|
||||
|
||||
const bestScore = Math.max(...scores.map(s => s.score), 0);
|
||||
const totalScore = scores.reduce((a, b) => a + b.score, 0);
|
||||
const totalTime = scores.reduce((a, b) => a + b.timeSeconds, 0);
|
||||
const bestTime = Math.min(...scores.map(s => s.timeSeconds), Infinity);
|
||||
const perfectGames = scores.filter(s => s.isPerfect).length;
|
||||
const winRate = (perfectGames / scores.length) * 100;
|
||||
|
||||
return {
|
||||
totalGames: scores.length,
|
||||
bestScore: bestScore,
|
||||
averageScore: Math.round(totalScore / scores.length),
|
||||
averageTime: Math.round(totalTime / scores.length),
|
||||
bestTime: bestTime === Infinity ? 0 : bestTime,
|
||||
winRate: Math.round(winRate),
|
||||
perfectGames: perfectGames
|
||||
};
|
||||
function getPlayerStatistics() {
|
||||
return LeaderboardManager.getPlayerStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Déboguer : afficher tous les scores en console
|
||||
* Déboguer : afficher l'historique local du joueur en console
|
||||
*/
|
||||
function debugScores() {
|
||||
console.table(LeaderboardManager.getAllScores());
|
||||
console.table(LeaderboardManager._getLocalHistory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Déboguer : nettoyer tous les scores
|
||||
* Déboguer : effacer les statistiques locales du joueur
|
||||
* (le classement en ligne partagé n'est pas affecté)
|
||||
*/
|
||||
function clearAllScores() {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer tous les scores?')) {
|
||||
localStorage.removeItem('nextgn_leaderboard');
|
||||
console.log('✓ Tous les scores ont été supprimés');
|
||||
location.reload();
|
||||
}
|
||||
LeaderboardManager.clearLocalHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
index.html
25
index.html
@@ -32,8 +32,8 @@
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="#formations">Exercices interactifs</a>
|
||||
<a href="#documentation">Documentation</a>
|
||||
<a href="#rapports">Rapports de veille</a>
|
||||
<a href="#documentation" style="display:none;">Documentation</a>
|
||||
<a href="#rapports" style="display:none;">Rapports de veille</a>
|
||||
<a href="#about">À propos</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
@@ -53,8 +53,8 @@
|
||||
|
||||
<div class="cta-group">
|
||||
<a href="#formations" class="btn btn-primary">Suivre les exercices interactifs</a>
|
||||
<a href="#documentation" class="btn btn-secondary">Documentation</a>
|
||||
<a href="#rapports" class="btn btn-secondary">Rapports de veille</a>
|
||||
<a href="#documentation" class="btn btn-secondary" style="display:none;">Documentation</a>
|
||||
<a href="#rapports" class="btn btn-secondary" style="display:none;">Rapports de veille</a>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
@@ -137,11 +137,24 @@
|
||||
<a href="exercices/memory-pictogrammes/memory.html" class="formation-link">Jouer</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Correction : page unique pour tous les exercices -->
|
||||
<div class="formation-card">
|
||||
<div class="formation-header">
|
||||
<div class="formation-icon">✅</div>
|
||||
<h3 class="formation-title">Corriger un exercice</h3>
|
||||
<p class="formation-subtitle">Correction des rendus</p>
|
||||
</div>
|
||||
<div class="formation-body">
|
||||
<p class="formation-description">Déposez le PDF exporté par un groupe et obtenez la lecture de son rendu. Page unique de correction pour l'ensemble des exercices.</p>
|
||||
<a href="exercices/corriger-exercice.html" class="formation-link">Corriger</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DOCUMENTATION TECHNIQUE SECTION -->
|
||||
<section id="documentation" class="documentation-section">
|
||||
<section id="documentation" class="documentation-section" style="display:none;">
|
||||
<div class="section-header">
|
||||
<h2>Documentation technique et biologie des espèces</h2>
|
||||
<p>Accédez à notre base de connaissances : études détaillées, analyses scientifiques et rapports techniques sur les nuisibles, les biocides et les enjeux réglementaires.</p>
|
||||
@@ -178,7 +191,7 @@
|
||||
</section>
|
||||
|
||||
<!-- RAPPORTS DE VEILLE HEBDOMADAIRES -->
|
||||
<section id="rapports" class="rapports-section">
|
||||
<section id="rapports" class="rapports-section" style="display:none;">
|
||||
<div class="section-header">
|
||||
<h2>Rapports de veille hebdomadaires</h2>
|
||||
<p>Suivez chaque semaine l'actualité réglementaire, les opportunités de marché et les tendances du secteur Certibiocide en France et en Europe.</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user