/* ============================================================================= UQO — Notes de cours interactives — application cliente v2 (sans dépendance) Modules : stockage, thème, navigation, recherche, infobulles, séance (lecture, sommaire, objectifs, révision, surlignage, notes, audio), widgets visuels, quiz, fiches, calculateurs, entraînement, jeux, progression, carte du cours. ============================================================================= */ (() => { "use strict"; const body = document.body; const COURSE = body.dataset.course || "cours"; const PREFIX = `uqo:${COURSE}:`; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); // --------------------------------------------------------------------------- // Stockage local // --------------------------------------------------------------------------- const store = { get(key, def) { try { const v = localStorage.getItem(PREFIX + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } }, set(key, val) { try { localStorage.setItem(PREFIX + key, JSON.stringify(val)); } catch (e) { /* quota */ } }, getGlobal(key, def) { try { const v = localStorage.getItem("uqo:" + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } }, setGlobal(key, val) { try { localStorage.setItem("uqo:" + key, JSON.stringify(val)); } catch (e) { /* quota */ } }, }; const DEFAULT_PROGRESS = { chapters: {}, quiz: {}, obj: {}, cards: {}, last: null, training: {}, games: {}, hl: [], notes: {}, sections: {} }; const progress = () => ({ ...DEFAULT_PROGRESS, ...store.get("progress", {}) }); const saveProgress = (p) => store.set("progress", p); // --------------------------------------------------------------------------- // Utilitaires // --------------------------------------------------------------------------- const nf = (d) => new Intl.NumberFormat("fr-CA", { minimumFractionDigits: d, maximumFractionDigits: d }); const fmtMoney = (v) => (isFinite(v) ? nf(0).format(Math.round(v)) + " $" : "—"); const fmtMoney2 = (v) => (isFinite(v) ? nf(2).format(v) + " $" : "—"); const fmtPct = (v, d = 2) => (isFinite(v) ? nf(d).format(v) + " %" : "—"); const fmtNum = (v, d = 2) => (isFinite(v) ? new Intl.NumberFormat("fr-CA", { maximumFractionDigits: d }).format(v) : "—"); const norm = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[’']/g, "'"); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const shuffle = (arr, seed) => { const a = arr.slice(); let s = seed || Math.floor(Math.random() * 1e9); const rnd = () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; }; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }; const fetchJSON = (() => { const cache = {}; return (url) => (cache[url] ||= fetch(url).then((r) => { if (!r.ok) throw new Error(url); return r.json(); })); })(); const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; const parseNumber = (s) => { const t = String(s).replace(/[\s  $%]/g, "").replace(/,/g, "."); const v = parseFloat(t); return isFinite(v) ? v : NaN; }; const R = { int: (a, b) => a + Math.floor(Math.random() * (b - a + 1)), pick: (arr) => arr[Math.floor(Math.random() * arr.length)], step: (a, b, st) => a + st * Math.floor(Math.random() * (Math.floor((b - a) / st) + 1)), money: fmtMoney, money2: fmtMoney2, pct: fmtPct, num: fmtNum, }; function toast(msg, kind = "") { let host = $("#toasts"); if (!host) { host = document.createElement("div"); host.id = "toasts"; host.className = "toasts"; body.appendChild(host); } const t = document.createElement("div"); t.className = "toast " + kind; t.innerHTML = msg; host.appendChild(t); requestAnimationFrame(() => t.classList.add("in")); setTimeout(() => { t.classList.remove("in"); setTimeout(() => t.remove(), 300); }, 3600); } function confetti() { const c = document.createElement("canvas"); c.className = "confetti"; body.appendChild(c); const ctx = c.getContext("2d"); c.width = innerWidth; c.height = innerHeight; const colors = ["#cca424", "#2976bb", "#107c4e", "#ffffff", "#f1d67a"]; const parts = Array.from({ length: 140 }, () => ({ x: innerWidth / 2 + (Math.random() - .5) * 200, y: innerHeight * .35, vx: (Math.random() - .5) * 14, vy: -Math.random() * 14 - 4, s: 4 + Math.random() * 6, c: colors[Math.floor(Math.random() * colors.length)], r: Math.random() * Math.PI })); let frame = 0; const tick = () => { ctx.clearRect(0, 0, c.width, c.height); parts.forEach((p) => { p.vy += .35; p.x += p.vx; p.y += p.vy; p.r += .1; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c; ctx.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * .6); ctx.restore(); }); if (frame++ < 130) requestAnimationFrame(tick); else c.remove(); }; tick(); } // --------------------------------------------------------------------------- // Thème, taille de police, révélation au défilement // --------------------------------------------------------------------------- function initTheme() { const saved = store.getGlobal("theme", null); const prefersDark = matchMedia && matchMedia("(prefers-color-scheme: dark)").matches; document.documentElement.dataset.theme = saved || (prefersDark ? "dark" : "light"); const fs = store.getGlobal("fs", null); if (fs) document.documentElement.dataset.fs = fs; $$("[data-action='theme']").forEach((b) => b.addEventListener("click", () => { const t = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = t; store.setGlobal("theme", t); })); $$("[data-action='fontsize']").forEach((b) => b.addEventListener("click", () => { const order = ["s", "", "l", "xl"]; const cur = document.documentElement.dataset.fs || ""; const next = order[(order.indexOf(cur) + 1) % order.length]; if (next) document.documentElement.dataset.fs = next; else delete document.documentElement.dataset.fs; store.setGlobal("fs", next); toast(`Taille du texte : ${{ s: "petite", "": "normale", l: "grande", xl: "très grande" }[next]}`); })); $$("[data-action='top']").forEach((b) => b.addEventListener("click", () => scrollTo({ top: 0, behavior: "smooth" }))); // révélation if ("IntersectionObserver" in window && !matchMedia("(prefers-reduced-motion: reduce)").matches) { const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }), { threshold: .08, rootMargin: "0px 0px -40px 0px" }); $$(".reveal").forEach((el) => io.observe(el)); } else $$(".reveal").forEach((el) => el.classList.add("in")); } // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- function initNav() { const menuBtn = $(".menu-btn"); const nav = $(".topnav"); if (menuBtn && nav) menuBtn.addEventListener("click", () => { const open = nav.classList.toggle("open"); menuBtn.setAttribute("aria-expanded", String(open)); }); $$(".dd > button").forEach((btn) => { const dd = btn.parentElement; btn.addEventListener("click", (e) => { e.stopPropagation(); const open = dd.classList.toggle("open"); btn.setAttribute("aria-expanded", String(open)); $$(".dd.open").forEach((o) => { if (o !== dd) o.classList.remove("open"); }); }); }); document.addEventListener("click", (e) => { if (!e.target.closest(".dd")) $$(".dd.open").forEach((o) => o.classList.remove("open")); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".dd.open").forEach((o) => o.classList.remove("open")); }); const p = progress(); $$(".dd-menu a[data-ch]").forEach((a) => { const ch = p.chapters[a.dataset.ch]; if (ch && ch.done) a.querySelector(".pastille")?.classList.add("done"); }); // barre de défilement globale (topbar) : ombre const tb = $(".topbar"); if (tb) addEventListener("scroll", () => tb.classList.toggle("scrolled", scrollY > 8), { passive: true }); } // --------------------------------------------------------------------------- // Recherche // --------------------------------------------------------------------------- const STOP = new Set(["le", "la", "les", "de", "des", "du", "un", "une", "et", "en", "au", "aux", "ou", "que", "qui", "dans", "pour", "par", "sur", "est", "sont", "pas", "ne", "se", "ce", "cette", "ces", "il", "elle", "on", "nous", "vous", "ils", "elles", "avec", "sans", "plus", "moins", "entre", "comme", "mais", "donc", "son", "sa", "ses", "leur", "leurs", "the", "of", "to", "and", "an", "a", "d", "l", "s", "n", "y", "à", "être", "avoir"]); const tokenize = (s) => norm(s).replace(/[^a-z0-9àâçéèêëîïôûùüÿœ\s-]/gi, " ").split(/[\s-]+/).filter((w) => w.length > 1); let searchIndex = null; async function loadSearch() { if (searchIndex) return searchIndex; const data = await fetchJSON("/data/search.json"); const inv = new Map(); data.forEach((d, i) => { const seen = new Set(); tokenize(d.title + " " + d.text).forEach((t) => { if (STOP.has(t) || seen.has(t)) return; seen.add(t); if (!inv.has(t)) inv.set(t, []); inv.get(t).push(i); }); d._normText = norm(d.title + " — " + d.text); }); searchIndex = { data, inv }; return searchIndex; } function search(query, limit = 30) { const { data, inv } = searchIndex; const qTokens = tokenize(query).filter((t) => !STOP.has(t)); if (!qTokens.length) return []; const scores = new Map(); const phrase = norm(query.trim()); qTokens.forEach((qt) => { for (const [tok, ids] of inv) { let w = 0; if (tok === qt) w = 3; else if (tok.startsWith(qt) && qt.length >= 3) w = 1.2; else if (qt.length >= 5 && tok.includes(qt)) w = .6; if (!w) continue; ids.forEach((i) => scores.set(i, (scores.get(i) || 0) + w / Math.sqrt(ids.length))); } }); const KIND_W = { definition: 1.6, formule: 1.5, aretenir: 1.3, synth: 1.2, gloss: 1.7, exemple: 1, exercice: .9, p: 1, tab: .9, fig: .9, attention: 1.1 }; const out = []; for (const [i, s] of scores) { const d = data[i]; let sc = s * (KIND_W[d.kind] || 1); if (phrase.length > 3 && d._normText.includes(phrase)) sc *= 2.2; if (norm(d.title).includes(phrase)) sc *= 1.5; out.push({ d, sc }); } out.sort((a, b) => b.sc - a.sc); return out.slice(0, limit).map((o) => o.d); } function snippet(text, query, len = 180) { const n = norm(text); const q = tokenize(query).filter((t) => !STOP.has(t)); let pos = -1; for (const t of q) { pos = n.indexOf(t); if (pos >= 0) break; } if (pos < 0) pos = 0; const start = Math.max(0, pos - 60); let s = text.slice(start, start + len); if (start > 0) s = "…" + s; if (start + len < text.length) s += "…"; const words = q.filter((t) => t.length > 2).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); if (!words.length) return esc(s); const re = new RegExp("(" + words.join("|") + ")", "gi"); const pattern = re.source.replace(/e/g, "[eéèêë]").replace(/a/g, "[aàâ]").replace(/i/g, "[iîï]").replace(/o/g, "[oôœ]").replace(/u/g, "[uûü]").replace(/c/g, "[cç]"); return esc(s).replace(new RegExp(pattern, "gi"), "$1"); } const KIND_LABEL = { definition: "Définition", formule: "Formule", exemple: "Exemple", exercice: "Exercice", aretenir: "À retenir", synth: "Synthèse", p: "Texte", tab: "Tableau", fig: "Figure", gloss: "Glossaire", attention: "Attention", quebec: "Québec", terrain: "Terrain", remarque: "Remarque", video: "Séance vidéo" }; function renderHits(container, hits, query) { if (!hits.length) { container.innerHTML = `
Aucun résultat pour « ${esc(query)} ».
`; return; } container.innerHTML = hits.map((d, i) => `
${KIND_LABEL[d.kind] || d.kind}${esc(d.title || d.chTitle)}
${snippet(d.text, query)}
${esc(d.where)}
`).join(""); } function initSearch() { const modal = $("#search-modal"); if (!modal) return; const input = $("input", modal); const results = $(".search-results", modal); const open = () => { modal.classList.add("open"); input.value = ""; results.innerHTML = '
Tapez un terme : définition, formule, notion, exemple…
'; setTimeout(() => input.focus(), 30); loadSearch(); }; const close = () => modal.classList.remove("open"); $$("[data-action='search']").forEach((b) => b.addEventListener("click", open)); modal.addEventListener("click", (e) => { if (e.target === modal) close(); }); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); modal.classList.contains("open") ? close() : open(); } else if (e.key === "/" && !/input|textarea|select/i.test(document.activeElement?.tagName || "") && !modal.classList.contains("open")) { e.preventDefault(); open(); } else if (e.key === "Escape" && modal.classList.contains("open")) close(); }); const run = debounce(async () => { const q = input.value.trim(); if (q.length < 2) { results.innerHTML = '
Tapez au moins deux caractères.
'; return; } await loadSearch(); renderHits(results, search(q), q); }, 120); input.addEventListener("input", run); input.addEventListener("keydown", (e) => { const hits = $$(".search-hit", results); const idx = hits.findIndex((h) => h.classList.contains("active")); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!hits.length) return; const ni = e.key === "ArrowDown" ? Math.min(hits.length - 1, idx + 1) : Math.max(0, idx - 1); hits.forEach((h) => h.classList.remove("active")); hits[ni].classList.add("active"); hits[ni].scrollIntoView({ block: "nearest" }); } else if (e.key === "Enter" && hits[idx]) location.href = hits[idx].href; }); const page = $(".search-page"); if (page) { const pin = $("input", page); const pres = $(".search-results", page); const doIt = debounce(async () => { const q = pin.value.trim(); history.replaceState(null, "", q ? `?q=${encodeURIComponent(q)}` : location.pathname); if (q.length < 2) { pres.innerHTML = ""; return; } await loadSearch(); renderHits(pres, search(q, 60), q); }, 150); pin.addEventListener("input", doIt); const q0 = new URLSearchParams(location.search).get("q"); if (q0) { pin.value = q0; doIt(); } pin.focus(); } } // --------------------------------------------------------------------------- // Infobulles glossaire // --------------------------------------------------------------------------- function initGlossaryTips() { const terms = $$(".motcle"); if (!terms.length) return; let gl = null, tip = null; const lookup = (t) => { const n = norm(t).replace(/[^a-z0-9 ]/g, "").trim(); if (!n) return null; if (gl.byNorm[n]) return gl.byNorm[n]; const alt = n.endsWith("s") ? n.slice(0, -1) : n + "s"; if (gl.byNorm[alt]) return gl.byNorm[alt]; const found = Object.keys(gl.byNorm).find((k) => k.length > 4 && (n.includes(k) || k.includes(n))); return found ? gl.byNorm[found] : null; }; const hide = () => { if (tip) { tip.remove(); tip = null; } }; const show = (el) => { const e = lookup(el.dataset.term || el.textContent); if (!e) return; hide(); tip = document.createElement("div"); tip.className = "tip"; tip.innerHTML = `${esc(e.term)}

${e.html}

Voir dans le glossaire →`; body.appendChild(tip); const r = el.getBoundingClientRect(); let left = r.left + scrollX; if (left + tip.offsetWidth > scrollX + innerWidth - 12) left = scrollX + innerWidth - tip.offsetWidth - 12; tip.style.left = left + "px"; tip.style.top = r.bottom + scrollY + 8 + "px"; }; terms.forEach((el) => { el.addEventListener("mouseenter", async () => { gl ||= await fetchJSON("/data/glossaire.json"); if (el.matches(":hover")) show(el); }); el.addEventListener("mouseleave", () => setTimeout(() => { if (!tip?.matches(":hover")) hide(); }, 150)); el.addEventListener("click", async (e) => { e.preventDefault(); gl ||= await fetchJSON("/data/glossaire.json"); tip ? hide() : show(el); }); }); document.addEventListener("click", (e) => { if (tip && !e.target.closest(".tip") && !e.target.closest(".motcle")) hide(); }); document.addEventListener("scroll", () => { if (tip) hide(); }, { passive: true }); } // --------------------------------------------------------------------------- // Page de séance // --------------------------------------------------------------------------- function initChapter() { const main = $(".chapter-page"); if (!main) return; const ch = main.dataset.chapter; const p = progress(); p.chapters[ch] ||= { pct: 0, done: false, visits: 0 }; const st = p.chapters[ch]; st.visits = (st.visits || 0) + 1; st.visitedAt = Date.now(); p.last = { ch, title: main.dataset.title, url: location.pathname }; saveProgress(p); const fill = $(".readbar-fill"); const tocBar = $(".toc-progress .bar i"); const tocPct = $(".toc-progress span"); const article = $(".ch-body"); const remaining = $("#read-remaining"); const words = Number(main.dataset.words || 0); let maxPct = st.pct || 0; const update = () => { const rect = article.getBoundingClientRect(); const total = rect.height - innerHeight * .6; const seen = Math.min(Math.max(-rect.top + innerHeight * .4, 0), total); const pct = total > 0 ? Math.round((seen / total) * 100) : 100; if (fill) fill.style.width = pct + "%"; if (remaining) { const m = Math.max(0, Math.round(words * (1 - pct / 100) / 180)); remaining.textContent = m > 0 ? `≈ ${m} min restantes` : "Lecture terminée"; } if (pct > maxPct) { maxPct = pct; const pp = progress(); pp.chapters[ch] = { ...(pp.chapters[ch] || {}), pct: maxPct, done: maxPct >= 92 || (pp.chapters[ch] || {}).done, scroll: scrollY }; saveProgress(pp); } if (tocBar) tocBar.style.width = maxPct + "%"; if (tocPct) tocPct.textContent = maxPct + " % lu"; }; let ticking = false; addEventListener("scroll", () => { if (!ticking) { requestAnimationFrame(() => { update(); ticking = false; }); ticking = true; } }, { passive: true }); update(); addEventListener("scroll", debounce(() => { const pp = progress(); if (pp.chapters[ch]) { pp.chapters[ch].scroll = scrollY; saveProgress(pp); } }, 400), { passive: true }); if (!location.hash && st.scroll && st.scroll > 400 && new URLSearchParams(location.search).get("reprendre") === "1") setTimeout(() => scrollTo({ top: st.scroll, behavior: "auto" }), 50); // scrollspy sommaire + stepper + sections lues const links = $$(".ch-toc li a[href^='#'], .stepper a[href^='#']"); const heads = $$(".prose h2.h-section"); const readSections = new Set((p.sections[ch] || [])); const spy = () => { const y = scrollY + 130; let cur = null; heads.forEach((h, i) => { if (h.offsetTop <= y) cur = h; }); $$(".ch-toc li a, .stepper a").forEach((a) => a.classList.remove("active")); const allH = $$(".prose h2, .prose h3"); let curAny = allH[0]; for (const h of allH) { if (h.offsetTop <= y) curAny = h; else break; } if (curAny) $$(`.ch-toc li a[href="#${curAny.id}"]`).forEach((a) => a.classList.add("active")); if (cur) { $$(`.stepper a[href="#${cur.id}"]`).forEach((a) => a.classList.add("active")); heads.forEach((h) => { if (h !== cur && h.offsetTop + 200 < y) readSections.add(h.id); }); $$(".stepper a").forEach((a) => a.classList.toggle("done", readSections.has(a.getAttribute("href").slice(1)))); $$(".ch-toc li.lvl2 a").forEach((a) => a.classList.toggle("done", readSections.has(a.getAttribute("href").slice(1)))); } }; addEventListener("scroll", debounce(() => { spy(); const pp = progress(); pp.sections[ch] = [...readSections]; saveProgress(pp); }, 80), { passive: true }); spy(); // objectifs const objs = $$(".obj-cb"); const objProg = $(".obj-progress"); const refreshObj = () => { const n = objs.filter((c) => c.checked).length; if (objProg) objProg.textContent = objs.length ? `${n}/${objs.length} atteint${n > 1 ? "s" : ""}` : ""; if (objs.length && n === objs.length && objs.length > 0) $("#objectifs")?.classList.add("complete"); else $("#objectifs")?.classList.remove("complete"); }; objs.forEach((c) => { c.checked = !!p.obj[c.dataset.obj]; c.addEventListener("change", () => { const pp = progress(); pp.obj[c.dataset.obj] = c.checked; saveProgress(pp); refreshObj(); if (objs.every((x) => x.checked)) toast("Tous les objectifs de la séance sont cochés.", "ok"); }); }); refreshObj(); // mode révision const revBtns = $$("[data-action='revision']"); const setRev = (on) => { body.classList.toggle("revision", on); revBtns.forEach((b) => b.setAttribute("aria-pressed", String(on))); store.setGlobal("revision", on); }; revBtns.forEach((b) => b.addEventListener("click", () => setRev(!body.classList.contains("revision")))); $("[data-action='revision-off']")?.addEventListener("click", () => setRev(false)); if (store.getGlobal("revision", false)) setRev(true); $("[data-action='toggle-solutions']")?.addEventListener("click", (e) => { const dets = $$("details.box-sol"); const anyClosed = dets.some((d) => !d.open); dets.forEach((d) => (d.open = anyClosed)); e.currentTarget.textContent = anyClosed ? "Replier les solutions" : "Révéler toutes les solutions"; }); document.addEventListener("keydown", (e) => { if (/input|textarea|select/i.test(document.activeElement?.tagName || "") || e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === "ArrowRight") { const n = $(".ch-pager a.next"); if (n) location.href = n.href; } if (e.key === "ArrowLeft") { const n = $(".ch-pager a.prev"); if (n) location.href = n.href; } }); initHighlights(ch); initNotes(ch); initTTS(); Widgets.mountAll(); const qa = $("#quiz-app"); if (qa) Quiz.mount(qa, { chapter: ch, embedded: true }); } // --------------------------------------------------------------------------- // Surlignage personnel // --------------------------------------------------------------------------- function initHighlights(ch) { const prose = $(".prose"); if (!prose) return; let pop = null; const applyAll = () => { $$("mark.hl", prose).forEach((m) => { const t = document.createTextNode(m.textContent); m.replaceWith(t); }); prose.normalize(); const p = progress(); p.hl.filter((h) => h.ch === ch).forEach((h) => { const para = document.getElementById(h.pid); if (!para) return; const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT); const nodes = []; let n; while ((n = walker.nextNode())) nodes.push(n); const full = nodes.map((x) => x.data).join(""); const idx = full.indexOf(h.text); if (idx < 0) return; let pos = 0; const end = idx + h.text.length; nodes.forEach((node) => { const s = pos, e = pos + node.data.length; pos = e; const a = Math.max(idx, s), b = Math.min(end, e); if (a >= b) return; const range = document.createRange(); range.setStart(node, a - s); range.setEnd(node, b - s); const mark = document.createElement("mark"); mark.className = "hl" + (h.note ? " has-note" : ""); mark.dataset.hl = h.id; if (h.note) mark.title = h.note; try { range.surroundContents(mark); } catch (err) { /* chevauchement */ } }); }); renderNotesList(); }; const hidePop = () => { if (pop) { pop.remove(); pop = null; } }; prose.addEventListener("mouseup", () => setTimeout(() => { const sel = getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) { hidePop(); return; } const range = sel.getRangeAt(0); const para = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer.closest("p[id], li") : range.commonAncestorContainer.parentElement.closest("p[id], li"); if (!para || !prose.contains(para)) { hidePop(); return; } const pid = para.id || para.closest("p[id], [id]")?.id; if (!pid) { hidePop(); return; } const text = sel.toString(); hidePop(); pop = document.createElement("div"); pop.className = "hl-pop"; pop.innerHTML = ``; body.appendChild(pop); const r = range.getBoundingClientRect(); pop.style.left = Math.max(8, Math.min(r.left + scrollX + r.width / 2 - pop.offsetWidth / 2, scrollX + innerWidth - pop.offsetWidth - 8)) + "px"; pop.style.top = r.top + scrollY - pop.offsetHeight - 10 + "px"; const save = (note) => { const p = progress(); p.hl.push({ id: "h" + Date.now(), ch, pid, text, note: note || "", at: Date.now() }); saveProgress(p); sel.removeAllRanges(); hidePop(); applyAll(); toast("Passage surligné" + (note ? " avec une note." : "."), "ok"); }; $("[data-hl]", pop).addEventListener("click", () => save("")); $("[data-note]", pop).addEventListener("click", () => { const note = prompt("Votre note sur ce passage :"); if (note !== null) save(note); }); }, 10)); document.addEventListener("mousedown", (e) => { if (pop && !e.target.closest(".hl-pop")) hidePop(); }); prose.addEventListener("click", (e) => { const m = e.target.closest("mark.hl"); if (!m) return; const p = progress(); const h = p.hl.find((x) => x.id === m.dataset.hl); if (!h) return; const action = prompt(`Passage surligné :\n« ${h.text.slice(0, 120)}${h.text.length > 120 ? "…" : ""} »\n\nModifiez la note (laissez vide pour aucune note, tapez SUPPRIMER pour retirer le surlignage) :`, h.note || ""); if (action === null) return; if (action.trim().toUpperCase() === "SUPPRIMER") p.hl = p.hl.filter((x) => x.id !== h.id); else h.note = action; saveProgress(p); applyAll(); }); applyAll(); } // --------------------------------------------------------------------------- // Notes personnelles (panneau latéral) // --------------------------------------------------------------------------- function renderNotesList() { const list = $("#hl-list"); if (!list) return; const ch = $(".chapter-page")?.dataset.chapter; const p = progress(); const items = p.hl.filter((h) => h.ch === ch); list.innerHTML = items.length ? items.map((h) => `
  • « ${esc(h.text.slice(0, 90))}${h.text.length > 90 ? "…" : ""} »${h.note ? `
    ${esc(h.note)}
    ` : ""}
  • `).join("") : '
  • Sélectionnez un passage du texte pour le surligner ; il apparaîtra ici.
  • '; $$("[data-del]", list).forEach((b) => b.addEventListener("click", () => { const pp = progress(); pp.hl = pp.hl.filter((x) => x.id !== b.dataset.del); saveProgress(pp); $$("mark.hl").forEach((m) => { if (m.dataset.hl === b.dataset.del) m.replaceWith(document.createTextNode(m.textContent)); }); renderNotesList(); })); } function initNotes(ch) { const panel = $("#notes-panel"); if (!panel) return; const ta = $("textarea", panel); const p = progress(); ta.value = p.notes[ch] || ""; ta.addEventListener("input", debounce(() => { const pp = progress(); pp.notes[ch] = ta.value; saveProgress(pp); $("#notes-saved").textContent = "Enregistré"; setTimeout(() => ($("#notes-saved").textContent = ""), 1500); }, 400)); const toggle = (on) => { panel.classList.toggle("open", on); body.classList.toggle("notes-open", on); $$("[data-action='notes']").forEach((b) => b.setAttribute("aria-pressed", String(on))); }; $$("[data-action='notes']").forEach((b) => b.addEventListener("click", () => toggle(!panel.classList.contains("open")))); $("[data-action='notes-close']", panel)?.addEventListener("click", () => toggle(false)); $("[data-action='notes-export']", panel)?.addEventListener("click", () => { const pp = progress(); const title = $(".chapter-page").dataset.title; const hl = pp.hl.filter((h) => h.ch === ch); let md = `# ${title}\n\n## Mes notes\n\n${pp.notes[ch] || "(aucune)"}\n\n## Passages surlignés\n\n` + (hl.length ? hl.map((h) => `- « ${h.text} »${h.note ? `\n - Note : ${h.note}` : ""}`).join("\n") : "(aucun)") + "\n"; const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([md], { type: "text/markdown" })); a.download = `${COURSE}-seance-${ch}-notes.md`; a.click(); }); renderNotesList(); } // --------------------------------------------------------------------------- // Lecture audio (synthèse vocale) // --------------------------------------------------------------------------- function initTTS() { const btn = $("[data-action='tts']"); if (!btn) return; if (!("speechSynthesis" in window)) { btn.disabled = true; btn.title = "Synthèse vocale non disponible"; return; } const bar = $("#tts-bar"); const items = $$(".prose h2, .prose h3, .prose p, .prose li").filter((el) => !el.closest(".sidenote") && el.textContent.trim().length > 2); let i = 0, playing = false, utter = null; const voice = () => { const vs = speechSynthesis.getVoices(); return vs.find((v) => v.lang === "fr-CA") || vs.find((v) => v.lang.startsWith("fr")) || null; }; const clear = () => $$(".tts-current").forEach((e) => e.classList.remove("tts-current")); const speak = () => { if (i >= items.length) { stop(); toast("Lecture terminée."); return; } const el = items[i]; clear(); el.classList.add("tts-current"); el.scrollIntoView({ block: "center", behavior: "smooth" }); utter = new SpeechSynthesisUtterance(el.textContent.replace(/\s+/g, " ").trim()); const v = voice(); if (v) utter.voice = v; utter.lang = "fr-CA"; utter.rate = Number($("#tts-rate")?.value || 1); utter.onend = () => { if (playing) { i++; speak(); } }; speechSynthesis.speak(utter); }; const start = () => { // départ : premier élément visible const y = scrollY + 140; const idx = items.findIndex((el) => el.offsetTop >= y); i = idx >= 0 ? idx : 0; playing = true; bar.hidden = false; $("#tts-play").textContent = "⏸"; speak(); }; const stop = () => { playing = false; speechSynthesis.cancel(); clear(); bar.hidden = true; $("#tts-play").textContent = "▶"; }; btn.addEventListener("click", () => (playing ? stop() : start())); $("#tts-play")?.addEventListener("click", () => { if (speechSynthesis.speaking && !speechSynthesis.paused) { speechSynthesis.pause(); $("#tts-play").textContent = "▶"; } else if (speechSynthesis.paused) { speechSynthesis.resume(); $("#tts-play").textContent = "⏸"; } else start(); }); $("#tts-stop")?.addEventListener("click", stop); $("#tts-next")?.addEventListener("click", () => { if (!playing) return; speechSynthesis.cancel(); i++; speak(); }); $("#tts-prev")?.addEventListener("click", () => { if (!playing) return; speechSynthesis.cancel(); i = Math.max(0, i - 1); speak(); }); $("#tts-rate")?.addEventListener("change", () => { if (playing) { speechSynthesis.cancel(); speak(); } }); addEventListener("beforeunload", () => speechSynthesis.cancel()); } // --------------------------------------------------------------------------- // Widgets visuels (SVG) insérés dans les séances // --------------------------------------------------------------------------- const SVGNS = "http://www.w3.org/2000/svg"; const svgEl = (tag, attrs = {}, text) => { const el = document.createElementNS(SVGNS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); if (text !== undefined) el.textContent = text; return el; }; function chart(w, h) { const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, class: "wchart", role: "img" }); const add = (tag, attrs, text) => { const el = svgEl(tag, attrs, text); svg.appendChild(el); return el; }; return { svg, add, w, h }; } const ctrl = (c) => c.type === "select" ? `` : ``; const Widgets = { defs: {}, register(id, def) { this.defs[id] = def; }, mountAll() { $$(".widget[data-widget]").forEach((el) => this.mount(el)); }, mount(el) { const def = this.defs[el.dataset.widget]; if (!def) { el.remove(); return; } el.innerHTML = `
    Explorateur interactif

    ${def.title}

    ${def.desc}

    ${def.controls.map(ctrl).join("")}
    ${def.note ? `

    ${def.note}

    ` : ""}`; const form = $("form", el), view = $(".w-view", el); const values = () => { const v = {}; def.controls.forEach((c) => { const inp = form.elements[c.key]; v[c.key] = c.type === "select" ? inp.value : parseFloat(inp.value); const out = inp.parentElement.querySelector("output"); if (out) out.textContent = (c.fmt ? c.fmt(v[c.key]) : v[c.key]) + (c.unit ? " " + c.unit : ""); }); return v; }; const render = () => { view.innerHTML = ""; def.render(view, values(), el); }; form.addEventListener("input", render); render(); }, }; const W_CO = { bleu: "var(--bleu)", or: "var(--or)", vert: "var(--vert)", rouge: "var(--rouge)", gris: "var(--gris)", clair: "var(--bleu-clair)", violet: "#6b4fbb", teal: "#0f8b8d" }; function bars(view, rows, opts = {}) { // rows : [{label, value, color, note}] barres horizontales proportionnelles const max = opts.max || Math.max(...rows.map((r) => Math.abs(r.value)), 1); view.insertAdjacentHTML("beforeend", `
    ${rows.map((r) => `
    ${r.label}${r.fmt ? r.fmt(r.value) : fmtMoney(r.value)}
    `).join("")}
    `); } function kpis(view, list) { view.insertAdjacentHTML("beforeend", `
    ${list.map((k) => `
    ${k.label}${k.value}
    `).join("")}
    `); } function lineChart(view, series, opts) { // series: [{points:[[x,y]], color, label}], opts: {xmin,xmax,ymin,ymax,xfmt,yfmt,marker:[x,y], xlabel, ylabel, w, h} const w = opts.w || 560, h = opts.h || 260, m = { l: 62, r: 16, t: 14, b: 40 }; const c = chart(w, h); const X = (x) => m.l + (x - opts.xmin) / (opts.xmax - opts.xmin) * (w - m.l - m.r); const Y = (y) => h - m.b - (y - opts.ymin) / (opts.ymax - opts.ymin) * (h - m.t - m.b); for (let i = 0; i <= 4; i++) { const yv = opts.ymin + (opts.ymax - opts.ymin) * i / 4; c.add("line", { x1: m.l, x2: w - m.r, y1: Y(yv), y2: Y(yv), class: "grid" }); c.add("text", { x: m.l - 8, y: Y(yv) + 4, class: "tick", "text-anchor": "end" }, opts.yfmt ? opts.yfmt(yv) : fmtNum(yv, 0)); const xv = opts.xmin + (opts.xmax - opts.xmin) * i / 4; c.add("text", { x: X(xv), y: h - m.b + 18, class: "tick", "text-anchor": "middle" }, opts.xfmt ? opts.xfmt(xv) : fmtNum(xv, 1)); } c.add("line", { x1: m.l, x2: w - m.r, y1: Y(opts.ymin), y2: Y(opts.ymin), class: "axis" }); c.add("line", { x1: m.l, x2: m.l, y1: Y(opts.ymin), y2: Y(opts.ymax), class: "axis" }); if (opts.xlabel) c.add("text", { x: (m.l + w - m.r) / 2, y: h - 6, class: "alabel", "text-anchor": "middle" }, opts.xlabel); if (opts.ylabel) c.add("text", { x: 12, y: m.t + 4, class: "alabel" }, opts.ylabel); series.forEach((s) => { const d = s.points.map((pt, i) => (i ? "L" : "M") + X(pt[0]).toFixed(1) + " " + Y(Math.max(opts.ymin, Math.min(opts.ymax, pt[1]))).toFixed(1)).join(" "); c.add("path", { d, fill: "none", stroke: s.color || W_CO.bleu, "stroke-width": 2.5, "stroke-linejoin": "round" }); if (s.label) c.add("text", { x: X(s.points[s.points.length - 1][0]) - 4, y: Y(s.points[s.points.length - 1][1]) - 8, class: "slabel", fill: s.color || W_CO.bleu, "text-anchor": "end" }, s.label); }); (opts.markers || []).forEach((mk) => { c.add("line", { x1: X(mk[0]), x2: X(mk[0]), y1: Y(opts.ymin), y2: Y(mk[1]), class: "mline" }); c.add("line", { x1: m.l, x2: X(mk[0]), y1: Y(mk[1]), y2: Y(mk[1]), class: "mline" }); c.add("circle", { cx: X(mk[0]), cy: Y(mk[1]), r: 6, fill: W_CO.or, stroke: "#fff", "stroke-width": 2 }); if (mk[2]) c.add("text", { x: X(mk[0]) + 10, y: Y(mk[1]) - 10, class: "mlabel" }, mk[2]); }); view.appendChild(c.svg); return { X, Y, c }; } function stackBar(view, segments, total, opts = {}) { // segments: [{label, value, color}] — barre empilée horizontale unique + légende const sum = total || segments.reduce((a, s) => a + Math.max(0, s.value), 0); view.insertAdjacentHTML("beforeend", `
    ${segments.map((s) => ``).join("")}
    ${segments.map((s) => `${s.label} ${opts.fmt ? opts.fmt(s.value) : fmtMoney(s.value)} (${fmtPct(Math.max(0, s.value) / sum * 100, 1)})`).join("")}
    `); } function waterfall(view, steps, opts = {}) { // steps: [{label, delta, color, total?}] → SVG cascade const w = 600, h = 280, m = { l: 20, r: 20, t: 20, b: 70 }; const c = chart(w, h); let running = 0; const totals = []; steps.forEach((s) => { running = s.total !== undefined ? s.total : running + s.delta; totals.push(running); }); const max = Math.max(...totals, ...steps.map((s) => Math.abs(s.delta))) * 1.08; const bw = (w - m.l - m.r) / steps.length; const Y = (v) => h - m.b - v / max * (h - m.t - m.b); let prev = 0; steps.forEach((s, i) => { const x = m.l + i * bw + bw * .12; const cur = totals[i]; let y0, y1; if (s.total !== undefined) { y0 = Y(0); y1 = Y(cur); } else if (s.delta >= 0) { y0 = Y(prev); y1 = Y(cur); } else { y0 = Y(cur); y1 = Y(prev); } c.add("rect", { x, y: Math.min(y0, y1), width: bw * .76, height: Math.max(2, Math.abs(y0 - y1)), rx: 4, fill: s.color || (s.total !== undefined ? W_CO.bleu : s.delta >= 0 ? W_CO.vert : W_CO.rouge) }); c.add("text", { x: x + bw * .38, y: Math.min(y0, y1) - 6, class: "vlabel", "text-anchor": "middle" }, (opts.fmt || fmtMoney)(s.total !== undefined ? cur : s.delta)); const lbl = s.label.split("\n"); lbl.forEach((l, k) => c.add("text", { x: x + bw * .38, y: h - m.b + 18 + k * 14, class: "tick", "text-anchor": "middle" }, l)); if (i < steps.length - 1 && steps[i + 1].total === undefined) c.add("line", { x1: x + bw * .76, x2: x + bw + bw * .12, y1: Y(cur), y2: Y(cur), class: "mline" }); prev = cur; }); c.add("line", { x1: m.l, x2: w - m.r, y1: Y(0), y2: Y(0), class: "axis" }); view.appendChild(c.svg); } // --- définitions des widgets ------------------------------------------------ Widgets.register("rpv", { title: "Prix, évaluation et mise de fonds", desc: "Déplacez l’évaluation sous le prix accepté : l’écart se répercute entièrement sur la mise de fonds, parce que le prêteur retient la moindre des deux valeurs.", controls: [{ key: "prix", label: "Prix accepté", min: 250000, max: 900000, step: 5000, default: 450000, fmt: fmtMoney }, { key: "eval", label: "Valeur d’évaluation", min: 250000, max: 900000, step: 5000, default: 430000, fmt: fmtMoney }, { key: "rpv", label: "RPV maximal", min: 50, max: 95, step: 5, default: 80, unit: "%" }], render(view, v) { const ret = Math.min(v.prix, v.eval), pret = ret * v.rpv / 100, mise = v.prix - pret; stackBar(view, [{ label: "Prêt hypothécaire", value: pret, color: W_CO.bleu }, { label: "Mise de fonds", value: mise, color: W_CO.or }], v.prix); kpis(view, [{ label: "Valeur retenue", value: fmtMoney(ret) }, { label: "Mise de fonds", value: fmtMoney(mise), cls: "main" }, { label: "En % du prix", value: fmtPct(mise / v.prix * 100, 1), cls: mise / v.prix > .2 + 1e-9 ? "warn" : "" }, { label: "Assurance prêt", value: v.rpv > 80 ? "obligatoire" : "non requise", cls: v.rpv > 80 ? "warn" : "good" }]); }, }); Widgets.register("mutation", { title: "Les tranches des droits de mutation", desc: "Le barème est progressif par tranches : chaque portion du prix est taxée à son propre taux. Faites glisser le prix et observez la part de chaque tranche.", controls: [{ key: "prix", label: "Base d’imposition", min: 50000, max: 1500000, step: 5000, default: 477000, fmt: fmtMoney }, { key: "muni", label: "Taux municipal majoré au-delà de 500 000 $", min: 0, max: 3, step: .5, default: 0, unit: "%" }], render(view, v) { const b = v.prix; const t1 = Math.min(b, 62900) * .005, t2 = Math.max(0, Math.min(b, 315000) - 62900) * .01; let t3, t4 = 0; if (v.muni > 0 && b > 500000) { t3 = Math.max(0, 500000 - 315000) * .015; t4 = (b - 500000) * v.muni / 100; } else t3 = Math.max(0, b - 315000) * .015; const segs = [{ label: "0,5 % (≤ 62 900 $)", value: t1, color: W_CO.vert }, { label: "1,0 % (62 900 – 315 000 $)", value: t2, color: W_CO.clair }, { label: "1,5 % (> 315 000 $)", value: t3, color: W_CO.bleu }]; if (t4) segs.push({ label: `${v.muni} % municipal (> 500 000 $)`, value: t4, color: W_CO.rouge }); stackBar(view, segs, null, { fmt: fmtMoney2 }); kpis(view, [{ label: "Droits exigibles", value: fmtMoney2(t1 + t2 + t3 + t4), cls: "main" }, { label: "Taux effectif", value: fmtPct((t1 + t2 + t3 + t4) / b * 100) }, { label: "Taux marginal", value: fmtPct(b > 500000 && v.muni ? v.muni : b > 315000 ? 1.5 : b > 62900 ? 1 : .5, 1) }]); }, }); Widgets.register("offre-demande", { title: "Offre inélastique et choc de demande", desc: "L’offre de logements réagit lentement (pente raide à court terme). Déplacez la demande : le prix d’équilibre bouge beaucoup, la quantité peu. Assouplissez l’offre pour voir l’effet à long terme.", controls: [{ key: "dem", label: "Déplacement de la demande", min: -30, max: 30, step: 1, default: 0, unit: "%" }, { key: "elas", label: "Élasticité de l’offre", min: 1, max: 10, step: 1, default: 2 }, { key: "off", label: "Déplacement de l’offre (mises en chantier)", min: -20, max: 20, step: 1, default: 0, unit: "%" }], render(view, v) { const P = (q) => Math.max(0, 900 - 6 * q); // demande de base const dem = (q) => P(q) * (1 + v.dem / 100); const supp = (q) => 60 + (q - v.off) * (60 / v.elas); // équilibre : dem(q) = supp(q) let qe = 0; for (let q = 0; q <= 150; q += .25) { if (dem(q) <= supp(q)) { qe = q; break; } } const pe = supp(qe); const q0 = (() => { let q = 0; for (let x = 0; x <= 150; x += .25) { if (P(x) <= 60 + x * 30) { q = x; break; } } return q; })(); const p0 = 60 + q0 * 30; lineChart(view, [{ points: Array.from({ length: 61 }, (_, i) => [i * 2.5, dem(i * 2.5)]), color: W_CO.bleu, label: "Demande" }, { points: Array.from({ length: 61 }, (_, i) => [i * 2.5, supp(i * 2.5)]), color: W_CO.or, label: "Offre" }], { xmin: 0, xmax: 150, ymin: 0, ymax: 1000, xfmt: (x) => fmtNum(x, 0), yfmt: (y) => fmtNum(y, 0) + " k$", xlabel: "Quantité de logements (indice)", ylabel: "Prix", markers: [[qe, pe, `P* = ${fmtNum(pe, 0)} k$`]] }); kpis(view, [{ label: "Prix d’équilibre", value: fmtNum(pe, 0) + " k$", cls: "main" }, { label: "Variation vs référence", value: fmtPct((pe / p0 - 1) * 100, 1), cls: pe > p0 ? "warn" : "good" }, { label: "Quantité d’équilibre", value: fmtNum(qe, 1) }, { label: "Variation de quantité", value: fmtPct((qe / q0 - 1) * 100, 1) }]); }, note: "Illustration stylisée : à Gatineau, une hausse de la demande de 2020 à 2022 s’est traduite par +40 à 50 % de prix, l’offre ne pouvant suivre à court terme.", }); Widgets.register("cycle", { title: "Le cycle immobilier et ses indicateurs", desc: "Parcourez le cycle : à chaque phase correspondent des indicateurs typiques (mois d’inventaire, délai de vente, ratio vendu/demandé, mises en chantier).", controls: [{ key: "t", label: "Position dans le cycle", min: 0, max: 100, step: 1, default: 20, unit: "%" }], render(view, v) { const phases = [ { n: "Reprise", from: 0, to: 25, txt: "Les ventes repartent, l’inventaire fond, les délais se raccourcissent ; les prix commencent à monter. Mises en chantier encore faibles.", ind: ["Mois d’inventaire : 6 → 4", "Délai de vente : en baisse", "Ratio vendu/demandé : 97–99 %", "Mises en chantier : faibles, en hausse"] }, { n: "Expansion / sommet", from: 25, to: 50, txt: "Marché de vendeurs : surenchères, inventaire < 4 mois, délais très courts ; la construction s’emballe avec retard.", ind: ["Mois d’inventaire : < 4", "Délai de vente : très court", "Ratio vendu/demandé : > 100 %", "Mises en chantier : record"] }, { n: "Contraction", from: 50, to: 75, txt: "La demande fléchit (taux, accessibilité) alors que l’offre nouvelle arrive : l’inventaire gonfle, les prix stagnent puis reculent.", ind: ["Mois d’inventaire : 5 → 8", "Délai de vente : s’allonge", "Ratio vendu/demandé : 95–97 %", "Mises en chantier : en chute"] }, { n: "Creux", from: 75, to: 100, txt: "Marché d’acheteurs : inventaire > 8 mois, négociation, faillites de promoteurs ; la rareté future se prépare.", ind: ["Mois d’inventaire : > 8", "Délai de vente : long", "Ratio vendu/demandé : < 95 %", "Mises en chantier : minimales"] }, ]; const ph = phases.find((p) => v.t >= p.from && v.t < p.to) || phases[3]; const pts = Array.from({ length: 101 }, (_, i) => [i, 50 + 40 * Math.sin((i / 100) * 2 * Math.PI - Math.PI / 2)]); const cur = 50 + 40 * Math.sin((v.t / 100) * 2 * Math.PI - Math.PI / 2); const { X, Y, c } = lineChart(view, [{ points: pts, color: W_CO.bleu }], { xmin: 0, xmax: 100, ymin: 0, ymax: 100, xfmt: (x) => ["Reprise", "Sommet", "Contraction", "Creux", ""][Math.round(x / 25)] || "", yfmt: () => "", xlabel: "Temps (7 à 12 ans par cycle)", ylabel: "Prix / activité", markers: [[v.t, cur, ph.n]] }); phases.forEach((p, i) => c.add("rect", { x: X(p.from), y: Y(100), width: X(p.to) - X(p.from), height: Y(0) - Y(100), fill: i % 2 ? "rgba(41,118,187,.05)" : "rgba(204,164,36,.06)" })); view.insertAdjacentHTML("beforeend", `
    ${ph.n}

    ${ph.txt}

    `); }, }); Widgets.register("seuils", { title: "Ajustements net et brut : les seuils de prudence", desc: "Réglez les quatre ajustements d’un comparable. Le net additionne les signes (ils se compensent), le brut additionne les valeurs absolues. Repères : net ≤ 15 %, brut ≤ 25 %.", controls: [{ key: "prix", label: "Prix du comparable", min: 250000, max: 900000, step: 5000, default: 450000, fmt: fmtMoney }, { key: "a", label: "Temps", min: -40000, max: 40000, step: 1000, default: 12000, fmt: fmtMoney }, { key: "b", label: "Localisation", min: -40000, max: 40000, step: 1000, default: -15000, fmt: fmtMoney }, { key: "c", label: "Superficie", min: -40000, max: 40000, step: 1000, default: 20000, fmt: fmtMoney }, { key: "d", label: "Garage / extras", min: -40000, max: 40000, step: 1000, default: -8000, fmt: fmtMoney }], render(view, v) { const adj = [v.a, v.b, v.c, v.d]; const net = adj.reduce((s, x) => s + x, 0), brut = adj.reduce((s, x) => s + Math.abs(x), 0); const np = Math.abs(net) / v.prix * 100, bp = brut / v.prix * 100; view.insertAdjacentHTML("beforeend", `
    ${[["Ajustement net", np, 15], ["Ajustement brut", bp, 25]].map(([l, val, seuil]) => `
    ${l}${fmtPct(val, 1)}
    seuil ${seuil} %
    `).join("")}
    `); kpis(view, [{ label: "Prix ajusté", value: fmtMoney(v.prix + net), cls: "main" }, { label: "Net", value: fmtMoney(net) }, { label: "Brut", value: fmtMoney(brut) }, { label: "Fiabilité", value: np <= 15 && bp <= 25 ? "acceptable" : "réduite — justifier ou écarter", cls: np <= 15 && bp <= 25 ? "good" : "warn" }]); }, }); Widgets.register("age-vie", { title: "Dépréciation selon l’âge effectif", desc: "La méthode âge/vie fait croître la dépréciation linéairement avec l’âge effectif. Modifiez l’entretien (âge effectif) et la vie économique : la courbe et le point se déplacent.", controls: [{ key: "cn", label: "Coût neuf", min: 200000, max: 3000000, step: 10000, default: 600000, fmt: fmtMoney }, { key: "cur", label: "Entretien différé (curable)", min: 0, max: 100000, step: 1000, default: 10000, fmt: fmtMoney }, { key: "age", label: "Âge effectif", min: 0, max: 70, step: 1, default: 18, unit: "ans" }, { key: "vie", label: "Vie économique", min: 30, max: 90, step: 5, default: 60, unit: "ans" }], render(view, v) { const base = v.cn - v.cur; const D = (a) => (v.cur + base * Math.min(1, a / v.vie)) / v.cn * 100; lineChart(view, [{ points: Array.from({ length: 71 }, (_, a) => [a, D(a)]), color: W_CO.bleu, label: "% du coût neuf" }], { xmin: 0, xmax: 70, ymin: 0, ymax: 100, xfmt: (x) => fmtNum(x, 0) + " ans", yfmt: (y) => fmtNum(y, 0) + " %", xlabel: "Âge effectif", ylabel: "Dépréciation", markers: [[Math.min(70, v.age), D(v.age), fmtPct(D(v.age), 1)]] }); const inc = base * Math.min(1, v.age / v.vie); kpis(view, [{ label: "Curable", value: fmtMoney(v.cur) }, { label: "Incurable (âge/vie)", value: fmtMoney(inc) }, { label: "Dépréciation totale", value: fmtMoney(v.cur + inc), cls: "main" }, { label: "Valeur dépréciée", value: fmtMoney(v.cn - v.cur - inc), cls: "good" }, { label: "Vie restante", value: Math.max(0, v.vie - v.age) + " ans" }]); }, }); Widgets.register("cascade-revenus", { title: "La cascade des revenus, du RBP à la valeur", desc: "Chaque étape retranche ou ajoute : inoccupation, revenus accessoires, dépenses. Le RNE est ensuite capitalisé. Observez comme le ratio de dépenses pèse sur la valeur.", controls: [{ key: "n", label: "Logements", min: 2, max: 24, step: 1, default: 6 }, { key: "loyer", label: "Loyer mensuel moyen", min: 700, max: 2200, step: 25, default: 1050, fmt: fmtMoney }, { key: "inocc", label: "Inoccupation et mauvaises créances", min: 0, max: 10, step: .5, default: 4, unit: "% du RBP" }, { key: "ratio", label: "Dépenses d’exploitation", min: 20, max: 60, step: 1, default: 40, unit: "% du RBE" }, { key: "tga", label: "TGA", min: 4, max: 9, step: .25, default: 5.5, unit: "%" }], render(view, v) { const rbp = v.n * v.loyer * 12, inocc = rbp * v.inocc / 100, autres = v.n * 300, rbe = rbp - inocc + autres, dep = rbe * v.ratio / 100, rne = rbe - dep; waterfall(view, [{ label: "RBP", total: rbp, color: W_CO.bleu }, { label: "Inoccupation", delta: -inocc }, { label: "Revenus\naccessoires", delta: autres }, { label: "RBE", total: rbe, color: W_CO.clair }, { label: "Dépenses", delta: -dep }, { label: "RNE", total: rne, color: W_CO.vert }]); kpis(view, [{ label: "RNE", value: fmtMoney(rne) }, { label: `Valeur (RNE ÷ ${fmtNum(v.tga, 2)} %)`, value: fmtMoney(rne / (v.tga / 100)), cls: "main" }, { label: "Valeur par logement", value: fmtMoney(rne / (v.tga / 100) / v.n) }, { label: "Ratio dépenses/RBE", value: fmtPct(v.ratio, 0), cls: v.ratio < 35 || v.ratio > 45 ? "warn" : "good" }]); }, }); Widgets.register("sensibilite-tga", { title: "La valeur réagit au taux global d’actualisation", desc: "V = RNE ÷ TGA : la relation est hyperbolique. Un demi-point de TGA déplace la valeur de 7 à 10 %. Glissez le taux pour mesurer la sensibilité.", controls: [{ key: "rne", label: "RNE stabilisé", min: 30000, max: 600000, step: 5000, default: 128000, fmt: fmtMoney }, { key: "tga", label: "TGA retenu", min: 4, max: 9, step: .25, default: 5.5, unit: "%" }], render(view, v) { const V = (t) => v.rne / (t / 100); lineChart(view, [{ points: Array.from({ length: 41 }, (_, i) => [4 + i * .125, V(4 + i * .125)]), color: W_CO.bleu, label: "Valeur" }], { xmin: 4, xmax: 9, ymin: 0, ymax: V(4) * 1.05, xfmt: (x) => fmtNum(x, 2) + " %", yfmt: (y) => fmtNum(y / 1000, 0) + " k$", xlabel: "TGA", ylabel: "Valeur", markers: [[v.tga, V(v.tga), fmtMoney(V(v.tga))]] }); kpis(view, [{ label: "Valeur", value: fmtMoney(V(v.tga)), cls: "main" }, { label: "TGA − 0,5 pt", value: fmtMoney(V(v.tga - .5)) + ` (${fmtPct((V(v.tga - .5) / V(v.tga) - 1) * 100, 1)})`, cls: "good" }, { label: "TGA + 0,5 pt", value: fmtMoney(V(v.tga + .5)) + ` (${fmtPct((V(v.tga + .5) / V(v.tga) - 1) * 100, 1)})`, cls: "warn" }, { label: "MRN (1/TGA)", value: fmtNum(100 / v.tga, 2) }]); }, }); Widgets.register("dcf", { title: "Anatomie d’un DCF : flux actualisés et réversion", desc: "Chaque barre est la valeur présente d’un RNE annuel ; la dernière est la réversion actualisée. Voyez le poids de la réversion et l’effet du taux d’actualisation.", controls: [{ key: "rne", label: "RNE année 1", min: 50000, max: 500000, step: 5000, default: 128000, fmt: fmtMoney }, { key: "g", label: "Croissance du RNE", min: 0, max: 5, step: .25, default: 2.5, unit: "%/an" }, { key: "n", label: "Détention", min: 3, max: 10, step: 1, default: 5, unit: "ans" }, { key: "r", label: "Taux d’actualisation", min: 5, max: 12, step: .25, default: 7.5, unit: "%" }, { key: "tgaout", label: "TGA de sortie", min: 4, max: 9, step: .25, default: 5.75, unit: "%" }], render(view, v) { const r = v.r / 100, g = v.g / 100; let rne = v.rne, pv = 0; const rows = []; for (let t = 1; t <= v.n; t++) { const f = rne / Math.pow(1 + r, t); pv += f; rows.push({ label: `Année ${t}`, value: f, color: W_CO.bleu }); rne *= 1 + g; } const rev = rne / (v.tgaout / 100) * .97, pvRev = rev / Math.pow(1 + r, v.n); rows.push({ label: "Réversion (nette 3 %)", value: pvRev, color: W_CO.or }); bars(view, rows); kpis(view, [{ label: "VA des flux", value: fmtMoney(pv) }, { label: "VA de la réversion", value: fmtMoney(pvRev) }, { label: "Valeur DCF", value: fmtMoney(pv + pvRev), cls: "main" }, { label: "Poids de la réversion", value: fmtPct(pvRev / (pv + pvRev) * 100, 0), cls: pvRev / (pv + pvRev) > .75 ? "warn" : "" }]); }, }); Widgets.register("cascade-cout", { title: "Des coûts directs au coût neuf total", desc: "Les coûts indirects s’expriment en pourcentage des directs ; le profit entrepreneurial s’applique sur la somme des deux. Le coût neuf dépasse typiquement les directs de 30 à 40 %.", controls: [{ key: "d", label: "Coûts directs", min: 200000, max: 5000000, step: 10000, default: 1350000, fmt: fmtMoney }, { key: "ind", label: "Coûts indirects", min: 5, max: 35, step: 1, default: 19, unit: "% des directs" }, { key: "prof", label: "Profit entrepreneurial", min: 0, max: 25, step: 1, default: 12, unit: "% de (directs + indirects)" }], render(view, v) { const i = v.d * v.ind / 100, p = (v.d + i) * v.prof / 100; waterfall(view, [{ label: "Coûts directs", total: v.d, color: W_CO.bleu }, { label: "Indirects", delta: i, color: W_CO.clair }, { label: "Sous-total", total: v.d + i, color: W_CO.teal }, { label: "Profit", delta: p, color: W_CO.or }, { label: "Coût neuf", total: v.d + i + p, color: W_CO.vert }]); kpis(view, [{ label: "Coût neuf total", value: fmtMoney(v.d + i + p), cls: "main" }, { label: "Majoration sur directs", value: fmtPct((i + p) / v.d * 100, 1), cls: (i + p) / v.d < .28 || (i + p) / v.d > .45 ? "warn" : "good" }, { label: "Part des indirects", value: fmtPct(i / (v.d + i + p) * 100, 1) }, { label: "Part du profit", value: fmtPct(p / (v.d + i + p) * 100, 1) }]); }, }); Widgets.register("hbu", { title: "Le filtre du plus haut et meilleur usage", desc: "Quatre critères appliqués dans l’ordre : légal, physique, financier, puis productivité maximale. Modifiez le zonage et le marché pour voir quel usage survit.", controls: [{ key: "zon", label: "Zonage", type: "select", default: "res", options: [{ value: "res", label: "Résidentiel faible densité" }, { value: "mixte", label: "Mixte (commercial + résidentiel)" }, { value: "ind", label: "Industriel léger" }] }, { key: "marche", label: "Vigueur du marché", min: 60, max: 140, step: 5, default: 100, unit: "% (indice)" }, { key: "sol", label: "Capacité portante du sol", type: "select", default: "bon", options: [{ value: "bon", label: "Bon sol" }, { value: "argile", label: "Argile sensible (fondations coûteuses)" }] }], render(view, v) { const uses = [ { n: "Unifamiliale", legal: ["res", "mixte"], phys: true, resid: 120000, coutSol: 0 }, { n: "Triplex", legal: ["res", "mixte"], phys: true, resid: 165000, coutSol: 15000 }, { n: "Immeuble de 6 logements", legal: ["mixte"], phys: true, resid: 240000, coutSol: 45000 }, { n: "Commerce de détail + logements", legal: ["mixte"], phys: true, resid: 290000, coutSol: 60000 }, { n: "Entrepôt", legal: ["ind"], phys: true, resid: 130000, coutSol: 30000 }, { n: "Tour de 12 étages", legal: ["mixte"], phys: false, resid: 900000, coutSol: 400000 }, ]; const rows = uses.map((u) => { const legal = u.legal.includes(v.zon); const phys = u.phys && !(v.sol === "argile" && u.n.includes("Tour")); const resid = u.resid * v.marche / 100 - (v.sol === "argile" ? u.coutSol : 0); const fin = resid > 0; return { ...u, legal, phys, fin, resid, ok: legal && phys && fin }; }); const best = rows.filter((r) => r.ok).sort((a, b) => b.resid - a.resid)[0]; view.insertAdjacentHTML("beforeend", `${rows.map((r) => ``).join("")}
    Usage candidatLégalPhysiqueFinancierValeur résiduelle du terrain
    ${r.n}${r === best ? ' PHMU' : ""}${r.legal ? "✓" : "✗"}${r.legal ? (r.phys ? "✓" : "✗") : "—"}${r.legal && r.phys ? (r.fin ? "✓" : "✗") : "—"}${r.legal && r.phys ? fmtMoney(r.resid) : "—"}
    `); kpis(view, [{ label: "Plus haut et meilleur usage", value: best ? best.n : "aucun usage viable", cls: "main" }, { label: "Valeur du terrain (résiduelle)", value: best ? fmtMoney(best.resid) : "—" }]); }, note: "Données illustratives. Le critère légal borne tout : un usage plus rentable mais interdit par le zonage n’est pas le PHMU.", }); Widgets.register("harper", { title: "La règle 4-3-2-1 de Harper", desc: "La valeur d’un lot se concentre côté rue : 40 % dans le premier quart de profondeur, puis 30, 20 et 10 %. Faites varier la profondeur du lot sujet.", controls: [{ key: "vstd", label: "Valeur du lot standard", min: 50000, max: 500000, step: 5000, default: 180000, fmt: fmtMoney }, { key: "pstd", label: "Profondeur standard", min: 60, max: 240, step: 10, default: 120, unit: "pi" }, { key: "p", label: "Profondeur du lot sujet", min: 20, max: 240, step: 5, default: 90, unit: "pi" }, { key: "regle", label: "Règle", type: "select", default: "h", options: [{ value: "h", label: "Harper 40-30-20-10" }, { value: "d", label: "Davis 50-25-15-10" }] }], render(view, v) { const parts = v.regle === "d" ? [.5, .25, .15, .1] : [.4, .3, .2, .1]; const q = v.pstd / 4; let ratio = 0; const w = 560, h = 230, c = chart(w, h); const scale = (w - 80) / Math.max(v.pstd, v.p); for (let i = 0; i < 4; i++) { const cov = Math.max(0, Math.min(q, v.p - i * q)) / q; ratio += cov * parts[i]; const x = 40 + i * q * scale; c.add("rect", { x, y: 40, width: q * scale - 2, height: 110, rx: 3, fill: `rgba(41,118,187,${.15 + parts[i] * 1.4})` }); if (cov > 0) c.add("rect", { x, y: 40, width: cov * q * scale - 2, height: 110, rx: 3, fill: "none", stroke: W_CO.or, "stroke-width": 3 }); c.add("text", { x: x + q * scale / 2, y: 100, class: "vlabel", "text-anchor": "middle" }, fmtPct(parts[i] * 100, 0)); c.add("text", { x: x + q * scale / 2, y: 170, class: "tick", "text-anchor": "middle" }, `${Math.round(i * q)}–${Math.round((i + 1) * q)} pi`); } c.add("rect", { x: 20, y: 30, width: 16, height: 130, fill: W_CO.gris, opacity: .5 }); c.add("text", { x: 28, y: 190, class: "tick", "text-anchor": "middle" }, "rue"); const extra = v.p > v.pstd ? Math.min(2, (v.p - v.pstd) / q) * .05 : 0; ratio += extra; view.appendChild(c.svg); kpis(view, [{ label: "Part de la valeur standard", value: fmtPct(ratio * 100, 1) }, { label: "Valeur du lot sujet", value: fmtMoney(v.vstd * ratio), cls: "main" }, { label: "Valeur par pied de profondeur", value: fmtMoney2(v.vstd * ratio / v.p) }]); }, note: "Au-delà de la profondeur standard, le surplus est valorisé prudemment (5 % par quart supplémentaire, maximum deux).", }); Widgets.register("indexation", { title: "Indexer un coût avec l’IPCB", desc: "L’indice des prix de la construction de bâtiments (Statistique Canada, base 2023 = 100) permet de ramener un coût connu à la date d’évaluation. Choisissez les deux dates.", controls: [{ key: "c0", label: "Coût connu", min: 100000, max: 5000000, step: 10000, default: 1500000, fmt: fmtMoney }, { key: "y0", label: "Année du coût connu", min: 2015, max: 2026, step: 1, default: 2019 }, { key: "y1", label: "Année d’évaluation", min: 2015, max: 2026, step: 1, default: 2026 }], render(view, v) { const idx = { 2015: 62.1, 2016: 63.9, 2017: 66.4, 2018: 69.5, 2019: 71.6, 2020: 74.3, 2021: 88.4, 2022: 100.8, 2023: 100, 2024: 102.9, 2025: 105.5, 2026: 108.7 }; const f = idx[v.y1] / idx[v.y0]; lineChart(view, [{ points: Object.entries(idx).map(([y, i]) => [Number(y), i]), color: W_CO.bleu, label: "IPCB" }], { xmin: 2015, xmax: 2026, ymin: 50, ymax: 115, xfmt: (x) => String(Math.round(x)), yfmt: (y) => fmtNum(y, 0), xlabel: "Année", ylabel: "Indice (2023 = 100)", markers: [[v.y0, idx[v.y0], String(v.y0)], [v.y1, idx[v.y1], String(v.y1)]] }); kpis(view, [{ label: "Facteur d’indexation", value: fmtNum(f, 4) }, { label: "Coût indexé", value: fmtMoney(v.c0 * f), cls: "main" }, { label: "Variation", value: fmtPct((f - 1) * 100, 1), cls: f > 1 ? "warn" : "good" }]); }, note: "Valeurs d’indice illustratives, calées sur le profil réel (+19 % en 2021, +14 % en 2022, puis ≈ +3 %/an). Toujours utiliser le tableau 18-10-0289-01 à jour.", }); Widgets.register("ventilation", { title: "Ventiler la dépréciation physique", desc: "Curable d’abord, puis les composantes à courte durée de vie chacune selon son âge/vie, puis le résidu long terme. La barre montre la décomposition du coût neuf.", controls: [{ key: "cn", label: "Coût neuf", min: 500000, max: 5000000, step: 10000, default: 1800000, fmt: fmtMoney }, { key: "cur", label: "Entretien différé", min: 0, max: 200000, step: 5000, default: 45000, fmt: fmtMoney }, { key: "ctc", label: "Coût neuf des composantes CT", min: 100000, max: 1500000, step: 10000, default: 560000, fmt: fmtMoney }, { key: "ctr", label: "Âge/vie moyen des composantes CT", min: 0, max: 100, step: 5, default: 60, unit: "%" }, { key: "age", label: "Âge effectif du bâtiment", min: 0, max: 60, step: 1, default: 27, unit: "ans" }, { key: "vie", label: "Vie économique", min: 30, max: 80, step: 5, default: 50, unit: "ans" }], render(view, v) { const ct = Math.min(v.ctc, v.cn - v.cur) * v.ctr / 100; const base = Math.max(0, v.cn - v.cur - Math.min(v.ctc, v.cn - v.cur)); const lt = base * Math.min(1, v.age / v.vie); const reste = v.cn - v.cur - ct - lt; stackBar(view, [{ label: "Curable", value: v.cur, color: W_CO.rouge }, { label: "Incurable CT", value: ct, color: W_CO.or }, { label: "Incurable LT", value: lt, color: W_CO.bleu }, { label: "Valeur restante", value: reste, color: W_CO.vert }], v.cn); kpis(view, [{ label: "Dépréciation physique", value: fmtMoney(v.cur + ct + lt), cls: "main" }, { label: "En % du coût neuf", value: fmtPct((v.cur + ct + lt) / v.cn * 100, 1) }, { label: "Base long terme", value: fmtMoney(base) }, { label: "Âge/vie simple (comparaison)", value: fmtMoney(v.cn * Math.min(1, v.age / v.vie)) }]); }, }); Widgets.register("allocation", { title: "Allouer la dépréciation externe", desc: "L’externe frappe le terrain et les améliorations. Seule la part des améliorations se déduit du coût neuf déprécié ; la part terrain est déjà dans la valeur du terrain.", controls: [{ key: "perte", label: "Perte de revenu net annuelle", min: 0, max: 100000, step: 1000, default: 12000, fmt: fmtMoney }, { key: "tga", label: "TGA", min: 5, max: 10, step: .25, default: 7, unit: "%" }, { key: "vt", label: "Valeur du terrain", min: 50000, max: 1000000, step: 10000, default: 292000, fmt: fmtMoney }, { key: "vam", label: "Valeur des améliorations", min: 100000, max: 3000000, step: 10000, default: 845000, fmt: fmtMoney }], render(view, v) { const d = v.perte / (v.tga / 100), ra = v.vam / (v.vam + v.vt); const w = 560, h = 220, c = chart(w, h), cx = 120, cy = 110, r = 80; const a = ra * 2 * Math.PI; const arc = (a0, a1, col) => { const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0), x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1); c.add("path", { d: `M${cx} ${cy} L${x0} ${y0} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1} ${y1} Z`, fill: col }); }; arc(-Math.PI / 2, -Math.PI / 2 + a, W_CO.bleu); arc(-Math.PI / 2 + a, Math.PI * 1.5, W_CO.or); c.add("circle", { cx, cy, r: 46, fill: "var(--card)" }); c.add("text", { x: cx, y: cy + 5, class: "vlabel", "text-anchor": "middle" }, fmtPct(ra * 100, 0)); c.add("rect", { x: 240, y: 60, width: 14, height: 14, fill: W_CO.bleu }); c.add("text", { x: 262, y: 72, class: "tick" }, `Améliorations : ${fmtMoney(d * ra)} déduits du coût déprécié`); c.add("rect", { x: 240, y: 100, width: 14, height: 14, fill: W_CO.or }); c.add("text", { x: 262, y: 112, class: "tick" }, `Terrain : ${fmtMoney(d * (1 - ra))} déjà dans V_T`); view.appendChild(c.svg); kpis(view, [{ label: "Dépréciation externe totale", value: fmtMoney(d) }, { label: "Part améliorations à déduire", value: fmtMoney(d * ra), cls: "main" }, { label: "Ratio améliorations", value: fmtPct(ra * 100, 1) }]); }, }); Widgets.register("bail-vie", { title: "Améliorations locatives : le bail limite la vie utile", desc: "La vie utile retenue est le minimum de la vie physique et de la durée de bail restante (options probables incluses). Faites varier le bail.", controls: [{ key: "c", label: "Coût neuf des améliorations", min: 20000, max: 800000, step: 5000, default: 180000, fmt: fmtMoney }, { key: "age", label: "Âge", min: 0, max: 15, step: 1, default: 3, unit: "ans" }, { key: "vph", label: "Vie physique", min: 5, max: 25, step: 1, default: 15, unit: "ans" }, { key: "bail", label: "Bail restant", min: 1, max: 20, step: 1, default: 4, unit: "ans" }], render(view, v) { const vie = Math.min(v.vph, v.age + v.bail); const rows = [{ label: "Vie physique", value: v.vph, color: W_CO.gris, fmt: (x) => x + " ans" }, { label: "Âge + bail restant", value: v.age + v.bail, color: W_CO.or, fmt: (x) => x + " ans" }, { label: "Vie utile retenue", value: vie, color: W_CO.vert, fmt: (x) => x + " ans" }, { label: "Âge écoulé", value: v.age, color: W_CO.rouge, fmt: (x) => x + " ans" }]; bars(view, rows, { max: Math.max(v.vph, v.age + v.bail) }); const dep = Math.min(1, v.age / vie); kpis(view, [{ label: "Dépréciation", value: fmtPct(dep * 100, 1) }, { label: "Valeur dépréciée", value: fmtMoney(v.c * (1 - dep)), cls: "main" }, { label: "Dépréciation annuelle", value: fmtMoney(v.c / vie) }, { label: "Facteur limitant", value: v.vph <= v.age + v.bail ? "vie physique" : "bail", cls: v.vph <= v.age + v.bail ? "" : "warn" }]); }, }); // --------------------------------------------------------------------------- // Quiz // --------------------------------------------------------------------------- const Quiz = { data: null, async load() { this.data ||= await fetchJSON("/data/quiz.json"); return this.data; }, async mount(root, opts) { const data = await this.load(); if (opts.chapter) { const qs = data.chapters[opts.chapter] || []; if (!qs.length) { root.closest(".ch-quiz")?.remove(); return; } this.run(root, qs, { title: `Testez-vous — séance ${Number(opts.chapter)}`, key: opts.chapter, embedded: opts.embedded }); return; } this.menu(root, data); }, menu(root, data) { const p = progress(); const items = Object.keys(data.chapters).sort().map((k) => { const best = p.quiz[k]?.best, n = data.chapters[k].length; return ``; }).join(""); const total = Object.values(data.chapters).reduce((a, b) => a + b.length, 0); const bestExam = p.quiz.exam?.best; root.innerHTML = `
    ${items}

    ${total} questions au total, avec rétroaction et renvoi vers la section des notes.

    `; $$("button[data-ch]", root).forEach((b) => b.addEventListener("click", () => { const k = b.dataset.ch; this.run(root, data.chapters[k], { title: `Séance ${Number(k)} — ${data.titles[k] || ""}`, key: k, back: () => this.menu(root, data) }); })); $$("button[data-exam]", root).forEach((b) => b.addEventListener("click", () => { const mode = b.dataset.exam; let pool = []; Object.keys(data.chapters).forEach((k) => { const n = Number(k); if (mode === "bloc1" && n > 7) return; if (mode === "bloc2" && n < 8) return; pool = pool.concat(data.chapters[k].map((q) => ({ ...q, ch: k }))); }); const qs = shuffle(pool).slice(0, mode === "1" ? 25 : 20); this.run(root, qs, { title: mode === "1" ? "Examen blanc" : b.querySelector("b").textContent, key: mode === "1" ? "exam" : "bloc-" + mode, timer: mode === "1" ? 1800 : 1200, back: () => this.menu(root, data) }); })); }, run(root, questions, opts) { const qs = questions.map((q) => ({ ...q, order: shuffle(q.c.map((_, i) => i)), answer: null })); let i = 0, score = 0, timerId = null, remaining = opts.timer || 0; const letters = "ABCDEF"; const fmtT = () => `${String(Math.floor(remaining / 60)).padStart(2, "0")}:${String(remaining % 60).padStart(2, "0")}`; const stopTimer = () => { if (timerId) clearInterval(timerId); timerId = null; }; const render = () => { const q = qs[i]; root.innerHTML = `
    ${opts.embedded ? `

    ${esc(opts.title)}

    ` : `

    ${esc(opts.title)}

    `}
    Question ${i + 1}/${qs.length}Score : ${score}${opts.timer ? `${fmtT()}` : ""}
    ${q.ch ? `Séance ${Number(q.ch)} · ` : ""}Question ${i + 1}
    ${q.q}
    ${q.order.map((ci, k) => ``).join("")}
    ${opts.back ? `` : ""}${q.url ? `Relire la section →` : ""}
    `; const fb = $(".q-feedback", root), next = $("[data-next]", root); $$(".q-choice", root).forEach((btn) => btn.addEventListener("click", () => { if (q.answer !== null) return; const ci = Number(btn.dataset.ci); q.answer = ci; const ok = ci === q.a; if (ok) score++; $$(".q-choice", root).forEach((b) => { b.disabled = true; const c = Number(b.dataset.ci); if (c === q.a) b.classList.add("correct"); else if (c === ci) b.classList.add("wrong"); }); fb.className = "q-feedback show " + (ok ? "ok" : "ko"); fb.innerHTML = `${ok ? "Bonne réponse." : "Réponse inexacte."}${q.e || ""}`; next.disabled = false; $(".quiz-score b", root).textContent = score; next.focus(); })); next.addEventListener("click", () => { i++; if (i < qs.length) render(); else finish(); }); $("[data-back]", root)?.addEventListener("click", () => { stopTimer(); opts.back(); }); document.addEventListener("keydown", keyHandler); if (opts.timer && !timerId) timerId = setInterval(() => { remaining--; const e2 = $("[data-timer]", root); if (e2) e2.textContent = fmtT(); if (remaining <= 0) { stopTimer(); finish(true); } }, 1000); }; const keyHandler = (e) => { if (!root.isConnected) { document.removeEventListener("keydown", keyHandler); return; } if (/input|textarea|select/i.test(document.activeElement?.tagName || "")) return; const idx = letters.indexOf(e.key.toUpperCase()); const btns = $$(".q-choice", root); if (idx >= 0 && btns[idx] && !btns[idx].disabled) btns[idx].click(); if (e.key === "Enter") { const n = $("[data-next]", root); if (n && !n.disabled) n.click(); } }; const finish = (timeout) => { stopTimer(); document.removeEventListener("keydown", keyHandler); const pct = Math.round((score / qs.length) * 100); const pp = progress(); const prev = pp.quiz[opts.key] || {}; pp.quiz[opts.key] = { best: Math.max(prev.best || 0, score), last: score, total: qs.length, at: Date.now(), attempts: (prev.attempts || 0) + 1 }; if (/^\d+$/.test(opts.key) && pct >= 70) pp.chapters[opts.key] = { ...(pp.chapters[opts.key] || {}), done: true }; saveProgress(pp); if (pct >= 90) confetti(); const verdict = pct >= 90 ? "Excellent — matière maîtrisée." : pct >= 70 ? "Bien — quelques points à revoir." : pct >= 50 ? "Passable — relisez la fiche synthèse." : "Insuffisant — reprenez la séance avant de continuer."; root.innerHTML = `
    ${timeout ? "Temps écoulé · " : ""}Résultat
    ${score}/${qs.length}
    ${verdict}

    ${pct} % de bonnes réponses${prev.best !== undefined ? ` · meilleur score précédent : ${prev.best}/${prev.total}` : ""}

    ${opts.back ? `` : ""}
    ${qs.map((q) => `
    ${q.answer === q.a ? "✓" : "✗"}
    ${q.q}
    ${q.answer !== q.a ? `Réponse : ${q.c[q.a]}` : ""}${q.url ? ` → notes` : ""}
    `).join("")}
    `; $("[data-retry]", root).addEventListener("click", () => { remaining = opts.timer || 0; this.run(root, questions, opts); }); $("[data-back]", root)?.addEventListener("click", () => opts.back()); renderProgressWidgets(); }; render(); }, }; // --------------------------------------------------------------------------- // Fiches // --------------------------------------------------------------------------- async function initFlashcards() { const root = $("#flash-app"); if (!root) return; const cards = await fetchJSON("/data/cards.json"); const p = progress(); const boxes = p.cards || {}; const chSel = $("#fc-ch"), typeSel = $("#fc-type"), modeSel = $("#fc-mode"); const chapters = [...new Set(cards.map((c) => c.ch))].sort((a, b) => (a === "G" ? 1 : b === "G" ? -1 : Number(a) - Number(b))); chSel.innerHTML = `` + chapters.map((c) => ``).join(""); const q0 = new URLSearchParams(location.search).get("ch"); if (q0 && chapters.includes(q0)) chSel.value = q0; let deck = [], idx = 0; const TYPE_LABEL = { def: "Définition", gloss: "Glossaire", form: "Formule" }; const renderStats = () => { const all = cards.filter((c) => (!chSel.value || c.ch === chSel.value) && (!typeSel.value || c.type === typeSel.value)); const acq = all.filter((c) => (boxes[c.id] || 0) >= 3).length, enc = all.filter((c) => (boxes[c.id] || 0) > 0 && boxes[c.id] < 3).length, rev = all.filter((c) => boxes[c.id] === 0).length; $("#fc-stats").innerHTML = `${all.length} fiches${acq} acquises${enc} en cours${rev} à revoir${all.length - acq - enc - rev} jamais vues`; }; const renderCard = () => { const stage = $("#fc-stage"); if (!deck.length) { stage.innerHTML = `
    Aucune fiche dans cette sélection. Changez les filtres ou le mode.
    `; $("#fc-nav").textContent = ""; return; } const c = deck[idx]; stage.innerHTML = `
    ${TYPE_LABEL[c.type]}${c.ch === "G" ? "Glossaire" : "Séance " + Number(c.ch)}
    ${c.front}
    Cliquer ou appuyer sur Espace pour retourner
    ${TYPE_LABEL[c.type]}${c.num || ""}
    ${c.back}
    `; const card = $(".fc-card", stage); card.addEventListener("click", (e) => { if (!e.target.closest("a")) card.classList.toggle("flipped"); }); $("#fc-nav").innerHTML = `Fiche ${idx + 1} / ${deck.length}Niveau : ${"●".repeat(boxes[c.id] || 0)}${"○".repeat(3 - (boxes[c.id] || 0))}`; }; const filterDeck = () => { const mode = modeSel.value; deck = cards.filter((c) => (!chSel.value || c.ch === chSel.value) && (!typeSel.value || c.type === typeSel.value)); if (mode === "review") deck = deck.filter((c) => (boxes[c.id] || 0) < 3); if (mode === "hard") deck = deck.filter((c) => boxes[c.id] === 0); deck = shuffle(deck); idx = 0; renderStats(); renderCard(); }; const grade = (ok) => { if (!deck.length) return; const c = deck[idx]; boxes[c.id] = ok ? Math.min(3, (boxes[c.id] || 0) + 1) : 0; const pp = progress(); pp.cards = boxes; saveProgress(pp); idx = (idx + 1) % deck.length; renderStats(); renderCard(); }; $("#fc-ok").addEventListener("click", () => grade(true)); $("#fc-ko").addEventListener("click", () => grade(false)); $("#fc-flip").addEventListener("click", () => $(".fc-card")?.classList.toggle("flipped")); $("#fc-skip").addEventListener("click", () => { if (deck.length) { idx = (idx + 1) % deck.length; renderCard(); } }); $("#fc-reset").addEventListener("click", () => { if (confirm("Réinitialiser la progression des fiches ?")) { for (const k in boxes) delete boxes[k]; const pp = progress(); pp.cards = {}; saveProgress(pp); filterDeck(); } }); [chSel, typeSel, modeSel].forEach((s) => s.addEventListener("change", filterDeck)); document.addEventListener("keydown", (e) => { if (/input|textarea|select/i.test(document.activeElement?.tagName || "")) return; if (e.key === " ") { e.preventDefault(); $(".fc-card")?.classList.toggle("flipped"); } if (e.key === "ArrowRight" || e.key.toLowerCase() === "j") grade(true); if (e.key === "ArrowLeft" || e.key.toLowerCase() === "r") grade(false); }); filterDeck(); } // --------------------------------------------------------------------------- // Calculateurs // --------------------------------------------------------------------------- function initTools() { const root = $("#tools-app"); if (!root || !window.UQO_TOOLS) return; const tools = window.UQO_TOOLS; const nav = $("#tools-nav"); if (nav) nav.innerHTML = tools.map((t) => `${esc(t.title)}`).join(""); const F = { money: fmtMoney, money2: fmtMoney2, pct: fmtPct, num: fmtNum, int: (v) => fmtNum(v, 0), text: (v) => v, pct1: (v) => fmtPct(v, 1), pct3: (v) => fmtPct(v, 3), num4: (v) => fmtNum(v, 4) }; root.innerHTML = tools.map((t) => `

    ${esc(t.title)}

    ${t.ref ? `${esc(t.ref.label)}` : ""}
    ${t.inputs.map((inp) => inp.group ? `
    ${esc(inp.group)}
    ` : inp.type === "select" ? `` : ``).join("")}
    ${t.desc ? `

    ${t.desc}

    ` : ""}${t.formula ? `
    ${t.formula}
    ` : ""}
    ${t.note ? `

    ${t.note}

    ` : ""}
    `).join(""); tools.forEach((t) => { const form = $(`form[data-tool="${t.id}"]`, root), out = $(`#${t.id} .tool-results`, root); const compute = () => { const v = {}; t.inputs.forEach((inp) => { if (inp.key) { const el = form.elements[inp.key]; v[inp.key] = inp.type === "select" ? el.value : parseFloat(String(el.value).replace(",", ".")); } }); let res; try { res = t.compute(v); } catch (e) { res = [{ label: "Erreur", value: String(e.message), fmt: "text", cls: "warn" }]; } out.innerHTML = res.map((r) => { if (r.table) return `${r.table.head.map((h) => ``).join("")}${r.table.rows.map((row) => `${row.map((c, i) => ``).join("")}`).join("")}
    ${esc(h)}
    ${i === 0 ? esc(String(c)) : typeof c === "number" ? (F[r.table.fmt?.[i] || "money"] || fmtMoney)(c) : esc(String(c))}
    `; const f = F[r.fmt || "money"] || fmtMoney; return `
    ${r.label}${f(r.value)}
    `; }).join(""); }; form.addEventListener("input", compute); form.addEventListener("submit", (e) => e.preventDefault()); compute(); }); $$(".reveal", root).forEach((el) => el.classList.add("in")); } // --------------------------------------------------------------------------- // Entraînement : problèmes générés // --------------------------------------------------------------------------- function initTraining() { const root = $("#training-app"); if (!root || !window.UQO_TRAINING) return; const gens = window.UQO_TRAINING; let current = null, gen = null, mode = "mix", streak = 0; const stats = () => progress().training || {}; const menu = $("#training-menu"); const renderMenu = () => { const s = stats(); menu.innerHTML = `` + gens.map((g) => { const st = s[g.id] || { ok: 0, n: 0 }; return ``; }).join(""); $$("[data-g]", menu).forEach((b) => b.addEventListener("click", () => { mode = b.dataset.g; renderMenu(); next(); })); }; const next = () => { gen = mode === "mix" ? R.pick(gens) : gens.find((g) => g.id === mode); current = gen.gen(R); root.innerHTML = `
    Séance ${Number(gen.ch)} · ${esc(gen.title)} · Série : ${streak}
    ${current.enonce}
    ${current.unit}
    Relire la section →
    `; const form = $("form", root), input = $("input", root), fb = $(".q-feedback", root); input.focus(); let answered = false; form.addEventListener("submit", (e) => { e.preventDefault(); if (answered) { next(); return; } const v = parseNumber(input.value); if (!isFinite(v)) { fb.className = "q-feedback show ko"; fb.innerHTML = "Entrez un nombre. Les espaces, le symbole $ ou % et la virgule décimale sont acceptés."; return; } const ok = Math.abs(v - current.answer) <= Math.max(Math.abs(current.answer) * current.tol, current.unit === "%" ? 0.02 : 1); answered = true; const p = progress(); p.training[gen.id] = { ok: (p.training[gen.id]?.ok || 0) + (ok ? 1 : 0), n: (p.training[gen.id]?.n || 0) + 1 }; saveProgress(p); streak = ok ? streak + 1 : 0; fb.className = "q-feedback show " + (ok ? "ok" : "ko"); fb.innerHTML = `${ok ? "Exact." : "Inexact."} Réponse attendue : ${current.unit === "%" ? fmtPct(current.answer) : fmtMoney2(current.answer)}${ok ? "" : ` (vous avez répondu ${current.unit === "%" ? fmtPct(v) : fmtMoney2(v)}).`}
    ${current.steps}
    `; $("button[type=submit]", form).textContent = "Problème suivant →"; if (ok && streak > 0 && streak % 5 === 0) { toast(`Série de ${streak} bonnes réponses !`, "ok"); confetti(); } renderMenu(); $(".streak", root).textContent = `Série : ${streak}`; }); $("[data-sol]", root).addEventListener("click", () => { answered = true; streak = 0; fb.className = "q-feedback show"; fb.innerHTML = `Solution. Réponse : ${current.unit === "%" ? fmtPct(current.answer) : fmtMoney2(current.answer)}
    ${current.steps}
    `; $("button[type=submit]", form).textContent = "Problème suivant →"; }); $("[data-skip]", root).addEventListener("click", next); }; renderMenu(); next(); } // --------------------------------------------------------------------------- // Jeux de révision // --------------------------------------------------------------------------- async function initGames() { const root = $("#games-app"); if (!root) return; const cards = await fetchJSON("/data/cards.json"); const seqs = window.UQO_SEQUENCES || []; const tabs = $$(".game-tabs button"); const saveGame = (key, res) => { const p = progress(); const g = p.games[key] || { played: 0, best: 0, total: 0 }; g.played++; g.best = Math.max(g.best, res.score); g.total = res.total; g.last = res.score; saveProgress(p); p.games[key] = g; saveProgress(p); }; const plain = (h) => { const d = document.createElement("div"); d.innerHTML = h; return d.textContent.replace(/\s+/g, " ").trim(); }; const pool = (type) => cards.filter((c) => (type ? c.type === type : c.type !== "form") && plain(c.back).length > 30); const assoc = () => { const picks = shuffle(pool()).slice(0, 6); const rights = shuffle(picks); let sel = null, matched = 0, errors = 0; const t0 = Date.now(); root.innerHTML = `

    Associez chaque terme à sa définition

    6 paires · 0 erreur(s)
    ${picks.map((c, i) => ``).join("")}
    ${rights.map((c) => ``).join("")}
    `; const onPick = (btn) => { if (btn.classList.contains("ok")) return; if (btn.dataset.l !== undefined) { $$(".assoc-item[data-l]", root).forEach((b) => b.classList.remove("sel")); btn.classList.add("sel"); sel = Number(btn.dataset.l); return; } if (sel === null) return; const card = picks[sel]; if (btn.dataset.r === card.id) { btn.classList.add("ok"); $(`.assoc-item[data-l="${sel}"]`, root).classList.add("ok"); matched++; sel = null; if (matched === 6) { const secs = Math.round((Date.now() - t0) / 1000); const score = Math.max(0, 6 - errors); saveGame("assoc", { score, total: 6 }); root.insertAdjacentHTML("beforeend", `
    ${score}/6
    Terminé en ${secs} s avec ${errors} erreur(s).
    `); $("[data-again]", root).addEventListener("click", assoc); if (errors === 0) confetti(); } } else { errors++; $("#g-err").textContent = errors; btn.classList.add("ko"); setTimeout(() => btn.classList.remove("ko"), 500); } }; $$(".assoc-item", root).forEach((b) => b.addEventListener("click", () => onPick(b))); }; const vraiFaux = () => { const src = shuffle(pool()).slice(0, 20); const rounds = []; for (let i = 0; i < 10; i++) { const c = src[i]; const wrong = Math.random() < .5; const other = wrong ? src[10 + i] : null; rounds.push({ term: c.front, def: wrong ? other.back : c.back, truth: !wrong, realTerm: other ? other.front : null, url: c.url }); } let i = 0, score = 0; const render = () => { const r = rounds[i]; root.innerHTML = `

    Vrai ou faux ?

    Question ${i + 1}/10 · score ${score}
    Cette définition correspond-elle au terme ?
    ${r.term}
    ${r.def}
    `; $$("[data-v]", root).forEach((b) => b.addEventListener("click", () => { const ans = b.dataset.v === "1"; const ok = ans === r.truth; if (ok) score++; $$("[data-v]", root).forEach((x) => (x.disabled = true)); const fb = $(".q-feedback", root); fb.className = "q-feedback show " + (ok ? "ok" : "ko"); fb.innerHTML = `${ok ? "Exact." : "Inexact."} ${r.truth ? "La définition correspond bien au terme." : `Cette définition est celle de ${r.realTerm}.`} Voir dans les notes →`; const n = $("[data-next]", root); n.hidden = false; n.focus(); n.addEventListener("click", () => { i++; if (i < 10) render(); else { saveGame("vf", { score, total: 10 }); root.innerHTML = `
    ${score}/10
    ${score >= 8 ? "Très bien !" : score >= 6 ? "Bien." : "À retravailler."}
    `; $("[data-again]", root).addEventListener("click", vraiFaux); if (score === 10) confetti(); } }); })); }; render(); }; const ordre = () => { if (!seqs.length) { root.innerHTML = "

    Aucune séquence disponible.

    "; return; } const seq = R.pick(seqs); const items = shuffle(seq[1].map((s, i) => ({ s, i }))); let chosen = []; const render = () => { root.innerHTML = `

    Remettez dans l’ordre

    ${esc(seq[0])}
    ${items.filter((it) => !chosen.includes(it)).map((it) => ``).join("") || 'Tous les éléments sont placés.'}
      ${chosen.map((it, k) => `
    1. ${it.s}
    2. `).join("")}
    Relire la section →
    `; $$(".ordre-item", root).forEach((b) => b.addEventListener("click", () => { chosen.push(items.find((it) => it.i === Number(b.dataset.i))); render(); })); $$("[data-undo]", root).forEach((b) => b.addEventListener("click", () => { chosen.splice(Number(b.dataset.undo), 1); render(); })); $("[data-other]", root).addEventListener("click", ordre); $("[data-check]", root).addEventListener("click", () => { const correct = chosen.filter((it, k) => it.i === k).length; const fb = $(".q-feedback", root); fb.className = "q-feedback show " + (correct === items.length ? "ok" : "ko"); fb.innerHTML = `${correct}/${items.length} en bonne position.${correct === items.length ? " Séquence parfaite." : ` Ordre correct :
      ${seq[1].map((s) => `
    1. ${s}
    2. `).join("")}
    `}`; $$(".ordre-list li", root).forEach((li, k) => li.classList.add(chosen[k].i === k ? "ok" : "ko")); saveGame("ordre", { score: correct, total: items.length }); if (correct === items.length) confetti(); }); }; render(); }; const modes = { assoc, vf: vraiFaux, ordre }; tabs.forEach((t) => t.addEventListener("click", () => { tabs.forEach((x) => x.setAttribute("aria-selected", String(x === t))); modes[t.dataset.game](); })); (modes[new URLSearchParams(location.search).get("jeu")] || assoc)(); } // --------------------------------------------------------------------------- // Progression (tableau de bord) // --------------------------------------------------------------------------- function initProgressPage() { const root = $("#progress-app"); if (!root || !window.UQO_META) return; const meta = window.UQO_META; const render = () => { const p = progress(); const done = meta.chapters.filter((c) => p.chapters[c.n]?.done).length; const readAvg = Math.round(meta.chapters.reduce((a, c) => a + (p.chapters[c.n]?.pct || 0), 0) / meta.chapters.length); const quizzes = Object.entries(p.quiz).filter(([k]) => /^\d+$/.test(k)); const quizAvg = quizzes.length ? Math.round(quizzes.reduce((a, [, q]) => a + q.best / q.total, 0) / quizzes.length * 100) : null; const objTotal = meta.chapters.reduce((a, c) => a + c.objectives, 0); const objDone = Object.values(p.obj).filter(Boolean).length; const cardsAcq = Object.values(p.cards).filter((b) => b >= 3).length; const tr = Object.values(p.training); const trN = tr.reduce((a, t) => a + t.n, 0), trOk = tr.reduce((a, t) => a + t.ok, 0); root.innerHTML = `
    ${done}/14séances terminées
    ${readAvg} %lecture moyenne
    ${quizAvg === null ? "—" : quizAvg + " %"}quiz : moyenne des meilleurs scores (${quizzes.length} séance${quizzes.length > 1 ? "s" : ""})
    ${objDone}/${objTotal}objectifs cochés
    ${cardsAcq}/${meta.cards}fiches acquises
    ${trN ? Math.round(trOk / trN * 100) + " %" : "—"}entraînement : ${trOk} justes sur ${trN}
    ${p.hl.length}passages surlignés · ${Object.values(p.notes).filter((n) => n && n.trim()).length} séance(s) avec notes
    ${Object.values(p.games).reduce((a, g) => a + g.played, 0)}parties jouées (association, vrai/faux, ordre)

    Séance par séance

    ${meta.chapters.map((c) => { const ch = p.chapters[c.n] || {}; const q = p.quiz[c.n]; const objs = Object.keys(p.obj).filter((k) => k.startsWith(`obj-${Number(c.n)}-`) && p.obj[k]).length; const secs = (p.sections[c.n] || []).length; return ``; }).join("")}
    SéanceLectureQuiz (meilleur)ObjectifsSections lues
    ${Number(c.n)} ${esc(c.title)}
    ${ch.pct || 0} %
    ${q ? `
    ${q.best}/${q.total} · ${q.attempts || 1} essai(s)` : 'non tenté'}
    ${objs}/${c.objectives}${secs}/${c.sections}${ch.done ? 'terminée' : ch.pct ? 'en cours' : ""}
    `; $("[data-export]", root).addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify(progress(), null, 1)], { type: "application/json" })); a.download = `${COURSE}-progression.json`; a.click(); }); $("[data-import]", root).addEventListener("change", (e) => { const f = e.target.files[0]; if (!f) return; f.text().then((t) => { try { const d = JSON.parse(t); if (!d.chapters) throw 0; saveProgress({ ...DEFAULT_PROGRESS, ...d }); toast("Progression importée.", "ok"); render(); } catch (err) { toast("Fichier invalide.", "ko"); } }); }); $("[data-notes-export]", root).addEventListener("click", () => { const pp = progress(); let md = `# ${meta.course} — mes notes\n\n`; meta.chapters.forEach((c) => { const n = pp.notes[c.n], hl = pp.hl.filter((h) => h.ch === c.n); if (!n && !hl.length) return; md += `## Séance ${Number(c.n)} — ${c.title}\n\n${n ? n + "\n\n" : ""}${hl.map((h) => `- « ${h.text} »${h.note ? `\n - Note : ${h.note}` : ""}`).join("\n")}\n\n`; }); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([md], { type: "text/markdown" })); a.download = `${COURSE}-notes.md`; a.click(); }); $("[data-reset]", root).addEventListener("click", () => { if (confirm("Effacer toute la progression de ce cours sur cet appareil (lecture, quiz, fiches, notes, surlignages) ?")) { saveProgress({ ...DEFAULT_PROGRESS }); render(); toast("Progression réinitialisée."); } }); }; render(); } // --------------------------------------------------------------------------- // Carte du cours // --------------------------------------------------------------------------- function initMap() { const map = $(".course-map"); if (!map || !window.UQO_MAP) return; const panel = $("#map-panel"); const p = progress(); $$("[data-node]", map).forEach((g) => { const n = g.dataset.node; const ch = p.chapters[n]; if (ch?.done) g.classList.add("done"); else if (ch?.pct) g.classList.add("started"); const show = () => { const d = window.UQO_MAP.chapters[n]; if (!panel || !d) return; $$("[data-node]", map).forEach((x) => x.classList.remove("active")); g.classList.add("active"); const q = p.quiz[n]; panel.innerHTML = `
    Séance ${Number(n)} · Partie ${d.part}

    ${d.title}

    ${d.counts.map((c) => `${c}`).join("")}${ch?.pct ? `${ch.pct} % lu` : ""}${q ? `quiz ${q.best}/${q.total}` : ""}

    Objectifs d’apprentissage

    Ouvrir la séanceQuizFiches
    `; }; g.addEventListener("mouseenter", show); g.addEventListener("focus", show); g.addEventListener("click", (e) => { if (e.target.closest("a")) return; show(); }); }); const first = $("[data-node]", map); if (first && panel) first.dispatchEvent(new Event("mouseenter")); } // --------------------------------------------------------------------------- // Glossaire, diapositives, sommaire latéral, ancres // --------------------------------------------------------------------------- function initGlossaryPage() { const input = $("#gl-search"); if (!input) return; const entries = $$(".gl-entry"), letters = $$(".gl-letter"), count = $("#gl-count"); const run = () => { const q = norm(input.value.trim()); let n = 0; const seen = new Set(); entries.forEach((e) => { const hit = !q || norm(e.textContent).includes(q); e.classList.toggle("hidden", !hit); if (hit) { n++; seen.add(e.dataset.letter); } }); letters.forEach((l) => l.classList.toggle("hidden", !seen.has(l.dataset.letter))); $$(".gl-letters a").forEach((a) => a.classList.toggle("off", !seen.has(a.textContent.trim()))); if (count) count.textContent = q ? `${n} terme${n > 1 ? "s" : ""} correspondant${n > 1 ? "s" : ""}` : `${entries.length} termes`; }; input.addEventListener("input", debounce(run, 80)); const q0 = new URLSearchParams(location.search).get("q"); if (q0) input.value = q0; run(); } function initSlides() { const tabs = $$(".slide-tabs button"), frame = $(".slide-viewer iframe"), title = $("#slide-title"), dl = $("#slide-download"); if (!tabs.length || !frame) return; const select = (b) => { tabs.forEach((t) => t.setAttribute("aria-selected", String(t === b))); frame.src = b.dataset.src + "#view=FitH"; if (title) title.textContent = b.dataset.title; if (dl) dl.href = b.dataset.src; history.replaceState(null, "", "#" + b.dataset.n); }; tabs.forEach((b) => b.addEventListener("click", () => select(b))); const h = location.hash.replace("#", ""); select(tabs.find((t) => t.dataset.n === h) || tabs[0]); } function initSideSpy() { const links = $$(".two-col .side li a[href^='#']"); if (!links.length) return; const heads = links.map((a) => document.getElementById(a.getAttribute("href").slice(1))).filter(Boolean); const spy = () => { let cur = heads[0]; const y = scrollY + 120; for (const h of heads) { if (h.offsetTop <= y) cur = h; else break; } links.forEach((a) => a.classList.toggle("active", cur && a.getAttribute("href") === "#" + cur.id)); }; addEventListener("scroll", debounce(spy, 60), { passive: true }); spy(); } function initHashDetails() { const openTarget = () => { if (!location.hash) return; const el = document.getElementById(decodeURIComponent(location.hash.slice(1))); if (!el) return; const det = el.closest("details"); if (det) det.open = true; el.classList.add("flash-target"); setTimeout(() => el.classList.remove("flash-target"), 2500); }; addEventListener("hashchange", openTarget); openTarget(); } function renderProgressWidgets() { const p = progress(); $$("[data-progress-ch]").forEach((el) => { const ch = p.chapters[el.dataset.progressCh] || {}; const q = p.quiz[el.dataset.progressCh]; const pct = Math.max(ch.pct || 0, ch.done ? 100 : 0); el.style.setProperty("--p", pct); el.dataset.done = ch.done ? "1" : "0"; el.title = `${pct} % lu${q ? ` · quiz ${q.best}/${q.total}` : ""}`; }); $$("[data-quiz-ch]").forEach((el) => { const q = p.quiz[el.dataset.quizCh]; if (q) { el.textContent = `Quiz ${q.best}/${q.total}`; el.classList.add(q.best / q.total >= .7 ? "chip-vert" : "chip-or"); el.hidden = false; } }); const resume = $("#resume-card"); if (resume) { const done = Object.values(p.chapters).filter((c) => c.done).length; const total = Number(resume.dataset.total || 14); const pct = Math.round((done / total) * 100); $(".bar i", resume).style.width = pct + "%"; $(".resume-pct", resume).textContent = `${done}/${total} séances terminées · ${pct} %`; if (p.last) { $(".resume-title", resume).textContent = p.last.title || `Séance ${Number(p.last.ch)}`; const a = $(".resume-link", resume); a.href = p.last.url + "?reprendre=1"; a.textContent = "Reprendre la lecture"; } const quizzes = Object.entries(p.quiz).filter(([k]) => /^\d+$/.test(k)); if (quizzes.length) { const sum = quizzes.reduce((a, [, q]) => a + q.best / q.total, 0) / quizzes.length; $(".resume-quiz", resume).textContent = `Quiz : ${quizzes.length} séance${quizzes.length > 1 ? "s" : ""} évaluée${quizzes.length > 1 ? "s" : ""}, moyenne ${Math.round(sum * 100)} %`; } } $$(".timeline-item[data-ch]").forEach((el) => { const ch = p.chapters[el.dataset.ch]; if (ch?.done) el.classList.add("done"); else if (ch?.pct) el.classList.add("started"); }); } // --------------------------------------------------------------------------- // Explorateurs de données du rapport (HPI ACI, FRED) // --------------------------------------------------------------------------- const PALETTE = ["#0f6180", "#cca424", "#b22a20", "#107c4e", "#6b4fbb", "#2a86b0", "#e07b39", "#0f8b8d", "#8a6d3b", "#555"]; function seriesChart(host, series, opts) { // series: [{label, color, points:[[x(index), y]]}], xLabels: [], hover tooltip const w = 760, h = 340, m = { l: 62, r: 16, t: 14, b: 42 }; const c = chart(w, h); const xs = opts.xLabels; const all = series.flatMap((s) => s.points.map((p) => p[1])).filter((v) => v !== null && isFinite(v)); let ymin = Math.min(...all), ymax = Math.max(...all); if (opts.zeroLine) { ymin = Math.min(ymin, 0); ymax = Math.max(ymax, 0); } const pad = (ymax - ymin) * .08 || 1; ymin -= pad; ymax += pad; const X = (i) => m.l + (i / Math.max(1, xs.length - 1)) * (w - m.l - m.r); const Y = (y) => h - m.b - (y - ymin) / (ymax - ymin) * (h - m.t - m.b); for (let i = 0; i <= 5; i++) { const yv = ymin + (ymax - ymin) * i / 5; c.add("line", { x1: m.l, x2: w - m.r, y1: Y(yv), y2: Y(yv), class: "grid" }); c.add("text", { x: m.l - 8, y: Y(yv) + 4, class: "tick", "text-anchor": "end" }, opts.yfmt(yv)); } const step = Math.max(1, Math.round(xs.length / 8)); for (let i = 0; i < xs.length; i += step) c.add("text", { x: X(i), y: h - m.b + 18, class: "tick", "text-anchor": "middle" }, xs[i].slice(0, 4)); if (opts.zeroLine) c.add("line", { x1: m.l, x2: w - m.r, y1: Y(0), y2: Y(0), class: "axis" }); c.add("line", { x1: m.l, x2: m.l, y1: Y(ymin), y2: Y(ymax), class: "axis" }); series.forEach((s) => { let d = "", pen = false; s.points.forEach(([i, v]) => { if (v === null || !isFinite(v)) { pen = false; return; } d += (pen ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " "; pen = true; }); c.add("path", { d, fill: "none", stroke: s.color, "stroke-width": 2.2, "stroke-linejoin": "round" }); }); const cursor = c.add("line", { x1: 0, x2: 0, y1: Y(ymin), y2: Y(ymax), class: "mline", opacity: 0 }); const dots = series.map((s) => c.add("circle", { r: 5, fill: s.color, stroke: "#fff", "stroke-width": 2, opacity: 0 })); host.appendChild(c.svg); const tip = document.createElement("div"); tip.className = "hpi-tip"; tip.style.display = "none"; host.appendChild(tip); c.svg.addEventListener("mousemove", (ev) => { const r = c.svg.getBoundingClientRect(); const px = (ev.clientX - r.left) / r.width * w; const i = Math.max(0, Math.min(xs.length - 1, Math.round((px - m.l) / (w - m.l - m.r) * (xs.length - 1)))); cursor.setAttribute("x1", X(i)); cursor.setAttribute("x2", X(i)); cursor.setAttribute("opacity", 1); const rows = []; series.forEach((s, k) => { const v = s.points[i]?.[1]; if (v === null || v === undefined || !isFinite(v)) { dots[k].setAttribute("opacity", 0); return; } dots[k].setAttribute("cx", X(i)); dots[k].setAttribute("cy", Y(v)); dots[k].setAttribute("opacity", 1); rows.push(`■ ${s.label} : ${opts.yfmt(v)}`); }); tip.innerHTML = `
    ${xs[i]}
    ${rows.join("
    ")}`; tip.style.display = "block"; tip.style.left = (X(i) / w * r.width) + "px"; tip.style.top = (ev.clientY - r.top - 8) + "px"; tip.style.transform = X(i) > w * .7 ? "translate(-100%, -110%)" : X(i) < w * .3 ? "translate(0, -110%)" : "translate(-50%, -110%)"; }); c.svg.addEventListener("mouseleave", () => { cursor.setAttribute("opacity", 0); dots.forEach((d) => d.setAttribute("opacity", 0)); tip.style.display = "none"; }); } async function initHPI() { const host = $("#hpi-explorer"); if (!host) return; const data = await fetchJSON(host.dataset.src); const names = Object.keys(data.series); const defaults = ["Québec (province)", "Canada", "RMR de Montréal", "RMR de Québec"]; const measures = [["Composite_HPI", "Indice composite (2005 = 100)", "idx"], ["Composite_Benchmark", "Prix de référence composite ($)", "money"], ["YOY", "Variation annuelle de l’indice composite (%)", "pct"], ["REBASE", "Indice composite rebasé à 100 au début de la période", "idx"], ["Single_Family_HPI", "Indice — unifamiliales", "idx"], ["Apartment_HPI", "Indice — appartements", "idx"], ["Single_Family_Benchmark", "Prix de référence — unifamiliales ($)", "money"], ["Apartment_Benchmark", "Prix de référence — appartements ($)", "money"]]; const years = [...new Set(data.dates.map((d) => d.slice(0, 4)))]; host.innerHTML = `
    Marchés
    ${names.map((n, i) => ``).join("")}

    ${data.source}

    `; const form = $("form", host); const render = () => { const m = form.elements.m.value; const y0 = Number(form.elements.y0.value), y1 = Math.max(Number(form.elements.y1.value), y0); form.elements.y0.parentElement.querySelector("output").textContent = years[y0]; form.elements.y1.parentElement.querySelector("output").textContent = years[y1]; const sel = $$("input[name=s]:checked", form).map((x) => x.value); const i0 = data.dates.findIndex((d) => d.startsWith(years[y0])); let i1 = data.dates.length - 1; for (let i = data.dates.length - 1; i >= 0; i--) if (data.dates[i].startsWith(years[y1])) { i1 = i; break; } const xs = data.dates.slice(i0, i1 + 1); const fmt = measures.find((x) => x[0] === m)[2]; const yfmt = fmt === "money" ? (v) => fmtNum(v / 1000, 0) + " k$" : fmt === "pct" ? (v) => fmtPct(v, 1) : (v) => fmtNum(v, 0); const series = sel.map((n) => { const s = data.series[n]; let arr; if (m === "YOY") arr = s.Composite_HPI.map((v, i) => (i >= 12 && v !== null && s.Composite_HPI[i - 12]) ? (v / s.Composite_HPI[i - 12] - 1) * 100 : null); else if (m === "REBASE") { const base = s.Composite_HPI[i0]; arr = s.Composite_HPI.map((v) => (v !== null && base ? v / base * 100 : null)); } else arr = s[m] || []; return { label: n, color: PALETTE[names.indexOf(n) % PALETTE.length], points: xs.map((_, k) => [k, arr[i0 + k] ?? null]), raw: arr }; }); const chartHost = $("#hpi-chart", host); chartHost.innerHTML = ""; if (!series.length) { chartHost.innerHTML = '

    Sélectionnez au moins un marché.

    '; return; } seriesChart(chartHost, series, { xLabels: xs, yfmt, zeroLine: m === "YOY" }); // indicateurs const rows = series.map((s) => { const vals = s.points.map((p) => p[1]).filter((v) => v !== null && isFinite(v)); const first = vals[0], last = vals[vals.length - 1]; const yrs = vals.length / 12; const cagr = first > 0 && yrs > 0 ? (Math.pow(last / first, 1 / yrs) - 1) * 100 : null; let peak = -Infinity, dd = 0; vals.forEach((v) => { peak = Math.max(peak, v); dd = Math.min(dd, (v / peak - 1) * 100); }); const yoy = vals.length > 12 ? (last / vals[vals.length - 13] - 1) * 100 : null; return `■ ${s.label}${yfmt(first)}${yfmt(last)}${fmt === "pct" ? "—" : fmtPct((last / first - 1) * 100, 1)}${cagr === null || fmt === "pct" ? "—" : fmtPct(cagr, 1)}${yoy === null || fmt === "pct" ? "—" : fmtPct(yoy, 1)}${fmt === "pct" ? "—" : fmtPct(dd, 1)}`; }); $("#hpi-kpis", host).innerHTML = `${rows.join("")}
    MarchéDébutFinVariation totaleCroissance annuelle12 derniers moisRecul max. depuis un sommet
    `; }; form.addEventListener("input", render); render(); } async function initMacro() { const host = $("#macro-explorer"); if (!host) return; const data = await fetchJSON(host.dataset.src); const ids = Object.keys(data); host.innerHTML = `

    Source : FRED, Federal Reserve Bank of St. Louis (séries OCDE et Statistique Canada). Le taux de change quotidien est moyenné par mois.

    `; const form = $("form", host); if (ids.includes("IRLTLT01CAM156N")) form.elements.b.value = "IRLTLT01CAM156N"; const render = () => { const a = data[form.elements.a.value], b = data[form.elements.b.value]; const since = form.elements.y.value; const pa = a.points.filter((p) => p[0] >= since); const xs = pa.map((p) => p[0]); const unitFmt = (u) => u === "%" ? (v) => fmtPct(v, 2) : u.startsWith("CAD") ? (v) => fmtNum(v, 3) : u === "habitants" ? (v) => fmtNum(v / 1e6, 2) + " M" : (v) => fmtNum(v, 0); const series = [{ label: `${a.label} (${a.unit})`, color: PALETTE[0], points: pa.map((p, i) => [i, p[1]]) }]; if (b && b.unit === a.unit) { const map = new Map(b.points); series.push({ label: `${b.label} (${b.unit})`, color: PALETTE[1], points: xs.map((d, i) => [i, map.has(d) ? map.get(d) : null]) }); } const chartHost = $("#macro-chart", host); chartHost.innerHTML = ""; seriesChart(chartHost, series, { xLabels: xs, yfmt: unitFmt(a.unit), zeroLine: a.unit === "%" }); const vals = pa.map((p) => p[1]); const f = unitFmt(a.unit); const kp = [{ label: `Dernière valeur (${xs[xs.length - 1]})`, value: f(vals[vals.length - 1]), cls: "main" }, { label: "Minimum de la période", value: f(Math.min(...vals)) }, { label: "Maximum de la période", value: f(Math.max(...vals)) }, { label: "Variation sur la période", value: a.unit === "%" ? `${fmtNum(vals[vals.length - 1] - vals[0], 2)} pt` : fmtPct((vals[vals.length - 1] / vals[0] - 1) * 100, 1) }]; if (b && b.unit !== a.unit) kp.push({ label: "Comparaison", value: "unités différentes : choisissez deux séries de même unité", cls: "warn" }); $("#macro-kpis", host).innerHTML = kp.map((k) => `
    ${k.label}${k.value}
    `).join(""); }; form.addEventListener("input", render); render(); } function initLightbox() { $$(".figimg img").forEach((img) => img.addEventListener("click", () => { const lb = document.createElement("div"); lb.className = "lightbox"; lb.innerHTML = ``; lb.addEventListener("click", () => lb.remove()); body.appendChild(lb); })); } // --------------------------------------------------------------------------- // Séances vidéo : lecteur personnalisé, chapitres, transcription synchronisée, storyboard // --------------------------------------------------------------------------- const hms = (t) => { t = Math.max(0, Math.floor(t || 0)); const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`; }; const parseT = (v) => { if (!v) return NaN; if (/^\d+(\.\d+)?$/.test(v)) return Number(v); const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(v); if (m && (m[1] || m[2] || m[3])) return (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0); const p = v.split(":").map(Number); return p.some(isNaN) ? NaN : p.reduce((a, b) => a * 60 + b, 0); }; const VI = { play: '', pause: '', back: '10', fwd: '10', vol: '', mute: '', cc: '', pip: '', theater: '', theaterOff: '', full: '', fullOff: '', skip: '', }; function initVideosIndex() { $$("[data-video-progress]").forEach((el) => { const st = store.get("video:" + el.dataset.videoProgress, null); const bar = $(".bar i", el), lab = $("span", el); if (!st || !st.dur) return; const pct = Math.round(Math.min(100, (st.max || st.t) / st.dur * 100)); if (pct < 1 && !st.done) return; bar.style.width = pct + "%"; if (st.done || pct >= 90) { el.classList.add("done"); lab.textContent = "Séance visionnée"; } else lab.textContent = `Vu à ${pct} % · reprendre à ${hms(st.t)}`; }); } async function initVideo() { const main = $(".video-page"); if (!main) return; const nn = main.dataset.video, video = $("#vid", main), player = $("#vid-player", main); const key = "video:" + nn; const startT = Number(main.dataset.start || 0); const p = progress(); p.last = { ch: nn, title: main.dataset.title, url: location.pathname }; saveProgress(p); let data = null; try { data = await fetchJSON(main.dataset.src); } catch (e) { $("#tr-list").innerHTML = '

    Transcription indisponible.

    '; } const chaps = (data && data.chapters) || []; const blocks = (data && data.blocks) || []; const sb = (data && data.storyboard) || []; const dur = () => (isFinite(video.duration) && video.duration) || (data && data.duration) || 0; const clamp = (t) => Math.max(0, Math.min(t, dur() ? dur() - .3 : t)); const seek = (t, play = true) => { try { video.currentTime = clamp(t); } catch (e) { /* not ready */ } if (play) video.play().catch(() => {}); }; const chapAt = (t) => { let ci = -1; for (let i = 0; i < chaps.length; i++) if (chaps[i].t <= t) ci = i; else break; return ci; }; const frameAt = (t) => { let bi = -1; for (let i = 0; i < sb.length; i++) if (sb[i].t <= t + .5) bi = i; else break; return sb[bi] || null; }; // ---- interface du lecteur (remplace les contrôles natifs) video.removeAttribute("controls"); const ui = document.createElement("div"); ui.className = "vp-ui"; const total = dur() || (chaps.length ? chaps[chaps.length - 1].end : 1); const segs = chaps.length ? chaps : [{ t: 0, end: total, title: "" }]; const li = (c) => $(`.vid-chaps .vc[data-t="${c.t}"]`, main); ui.innerHTML = `
    ${startT ? `` : ""}
    ${segs.map((c) => ``).join("")}
    0:00 / ${hms(total)}
    ${[.75, 1, 1.25, 1.5, 1.75, 2].map((r) => ``).join("")}
    ${document.pictureInPictureEnabled ? `` : ""}
    `; player.appendChild(ui); const q = (s) => $(s, ui); const playBtns = [q(".vp-big"), q(".vp-btn.play")]; const toggle = () => { video.paused ? video.play().catch(() => {}) : video.pause(); }; const flash = (svg) => { const f = q(".vp-flash"); f.innerHTML = svg; f.classList.remove("go"); void f.offsetWidth; f.classList.add("go"); }; let toastTimer; const vtoast = (msg) => { const t = q("#vp-toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 1400); }; playBtns.forEach((b) => b.addEventListener("click", toggle)); let tapWasIdle = false; q(".vp-click").addEventListener("pointerdown", () => { tapWasIdle = player.classList.contains("vp-idle"); }); q(".vp-click").addEventListener("click", () => { if (tapWasIdle && matchMedia("(pointer: coarse)").matches) return; toggle(); flash(video.paused ? VI.play : VI.pause); }); q(".vp-click").addEventListener("dblclick", () => fullscreen()); q(".vp-btn.back").addEventListener("click", () => { seek(video.currentTime - 10, !video.paused); vtoast("−10 s"); }); q(".vp-btn.fwd").addEventListener("click", () => { seek(video.currentTime + 10, !video.paused); vtoast("+10 s"); }); const setState = () => { player.dataset.state = video.paused ? (video.currentTime > 0 || player.dataset.state === "paused" ? "paused" : "idle") : "playing"; const ic = video.paused ? VI.play : VI.pause; q(".vp-btn.play").innerHTML = ic; q(".vp-btn.play").setAttribute("aria-label", video.paused ? "Lecture" : "Pause"); }; video.addEventListener("play", () => { player.dataset.state = "playing"; setState(); }); video.addEventListener("pause", () => { player.dataset.state = "paused"; setState(); }); // masquage automatique let idleTimer, overControls = false; const wake = () => { player.classList.remove("vp-idle"); clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (!video.paused && !q(".vp-menu.open") && !overControls) player.classList.add("vp-idle"); else wake(); }, 2600); }; q(".vp-controls").addEventListener("mouseenter", () => { overControls = true; }); q(".vp-controls").addEventListener("mouseleave", () => { overControls = false; }); ["mousemove", "touchstart", "keydown", "click"].forEach((ev) => player.addEventListener(ev, wake, { passive: true })); player.addEventListener("mouseleave", () => { if (!video.paused) player.classList.add("vp-idle"); }); video.addEventListener("play", wake); // barre de progression const seekEl = q(".vp-seek"), track = q(".vp-track"), knob = q(".vp-knob"), preview = q(".vp-preview"); const segEls = $$(".vp-seg", track); const tFromX = (x) => { const r = track.getBoundingClientRect(); return Math.max(0, Math.min(1, (x - r.left) / r.width)) * (dur() || total); }; const showPreview = (x) => { const t = tFromX(x); const r = track.getBoundingClientRect(); const px = Math.max(0, Math.min(1, (x - r.left) / r.width)); const f = frameAt(t); const ci = chapAt(t); preview.hidden = false; const img = $("img", preview); if (f && img.dataset.src !== f.src) { img.src = f.src; img.dataset.src = f.src; } img.style.visibility = f ? "visible" : "hidden"; $("b", preview).textContent = hms(t); $("span", preview).textContent = ci >= 0 ? chaps[ci].title : ""; const w = preview.offsetWidth || 220, half = w / 2 / r.width; const left = Math.max(half, Math.min(1 - half, px)) * 100; preview.style.left = `${left}%`; knob.style.left = `${px * 100}%`; knob.style.transform = "translate(-50%, -50%) scale(1)"; }; let dragging = false, wasPlaying = false; seekEl.addEventListener("pointermove", (e) => { if (!dragging) showPreview(e.clientX); else { showPreview(e.clientX); video.currentTime = clamp(tFromX(e.clientX)); } }); seekEl.addEventListener("pointerleave", () => { if (!dragging) { preview.hidden = true; knob.style.transform = ""; } }); seekEl.addEventListener("pointerdown", (e) => { dragging = true; wasPlaying = !video.paused; video.pause(); seekEl.classList.add("drag"); seekEl.setPointerCapture(e.pointerId); showPreview(e.clientX); video.currentTime = clamp(tFromX(e.clientX)); }); const endDrag = (e) => { if (!dragging) return; dragging = false; seekEl.classList.remove("drag"); preview.hidden = true; knob.style.transform = ""; video.currentTime = clamp(tFromX(e.clientX)); if (wasPlaying) video.play().catch(() => {}); }; seekEl.addEventListener("pointerup", endDrag); seekEl.addEventListener("pointercancel", endDrag); seekEl.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") { e.preventDefault(); seek(video.currentTime - 5, !video.paused); } if (e.key === "ArrowRight") { e.preventDefault(); seek(video.currentTime + 5, !video.paused); } }); // volume const volIn = q(".vp-vol input"), muteBtn = q(".vp-btn.mute"); const syncVol = () => { muteBtn.innerHTML = video.muted || video.volume === 0 ? VI.mute : VI.vol; volIn.value = video.muted ? 0 : video.volume; }; muteBtn.addEventListener("click", () => { video.muted = !video.muted; store.setGlobal("video-muted", video.muted); syncVol(); }); volIn.addEventListener("input", () => { video.volume = Number(volIn.value); video.muted = video.volume === 0; store.setGlobal("video-volume", video.volume); syncVol(); }); video.volume = store.getGlobal("video-volume", 1); video.muted = store.getGlobal("video-muted", false); syncVol(); // vitesse const rateBtn = q(".vp-btn.rate"), rateMenu = q(".rate-menu"); const setRate = (r) => { video.playbackRate = r; rateBtn.textContent = `${String(r).replace(".", ",")}×`; $$("button", rateMenu).forEach((b) => b.setAttribute("aria-checked", String(Number(b.dataset.rate) === r))); store.setGlobal("video-rate", r); }; rateBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = rateMenu.classList.toggle("open"); rateBtn.setAttribute("aria-expanded", String(open)); }); $$("button", rateMenu).forEach((b) => b.addEventListener("click", () => { setRate(Number(b.dataset.rate)); rateMenu.classList.remove("open"); rateBtn.setAttribute("aria-expanded", "false"); vtoast(`Vitesse ${b.textContent.trim()}`); })); document.addEventListener("click", (e) => { if (!e.target.closest(".vp-menu-wrap")) { rateMenu.classList.remove("open"); rateBtn.setAttribute("aria-expanded", "false"); } }); setRate(store.getGlobal("video-rate", 1)); // sous-titres const ccBtn = q(".vp-btn.cc"); const subs = () => Array.from(video.textTracks || []).find((t) => t.kind === "subtitles"); const setCC = (on) => { const t = subs(); if (t) t.mode = on ? "showing" : "hidden"; ccBtn.setAttribute("aria-pressed", String(on)); store.setGlobal("video-cc", on); }; Array.from(video.textTracks || []).forEach((t) => { t.mode = "hidden"; }); ccBtn.addEventListener("click", () => { const on = ccBtn.getAttribute("aria-pressed") !== "true"; setCC(on); vtoast(on ? "Sous-titres activés" : "Sous-titres désactivés"); }); setCC(store.getGlobal("video-cc", false)); // image dans l'image, cinéma, plein écran q(".vp-btn.pip")?.addEventListener("click", async () => { try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await video.requestPictureInPicture(); } catch (e) { vtoast("Image dans l’image non disponible"); } }); const theaterBtn = q(".vp-btn.theater"); const setTheater = (on) => { main.classList.toggle("theater", on); theaterBtn.setAttribute("aria-pressed", String(on)); theaterBtn.innerHTML = on ? VI.theaterOff : VI.theater; store.setGlobal("video-theater", on); }; theaterBtn.addEventListener("click", () => setTheater(!main.classList.contains("theater"))); if (innerWidth > 1100) setTheater(store.getGlobal("video-theater", false)); const fullBtn = q(".vp-btn.full"); const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen(); else if (player.requestFullscreen) player.requestFullscreen().catch(() => {}); else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); }; fullBtn.addEventListener("click", fullscreen); document.addEventListener("fullscreenchange", () => { fullBtn.innerHTML = document.fullscreenElement ? VI.fullOff : VI.full; }); // passer l'accueil const skipBtn = q(".vp-skip"); skipBtn?.addEventListener("click", () => { seek(startT); vtoast("Début du cours"); }); // ---- onglets du panneau const tabs = $$(".vid-tabs button", main), panes = $$(".vid-pane", main); const showPane = (name) => { tabs.forEach((b) => b.setAttribute("aria-selected", String(b.dataset.pane === name))); panes.forEach((pn) => { pn.hidden = pn.dataset.pane !== name; }); store.setGlobal("video-pane", name); }; tabs.forEach((b) => b.addEventListener("click", () => showPane(b.dataset.pane))); const savedPane = store.getGlobal("video-pane", "chapters"); if (tabs.some((b) => b.dataset.pane === savedPane)) showPane(savedPane); // ---- chapitres (liste) const chapEls = $$(".vid-chaps .vc", main); chapEls.forEach((el) => { $(".vc-btn", el).addEventListener("click", () => { seek(Number(el.dataset.t)); if (innerWidth <= 1100) player.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); // ---- transcription const list = $("#tr-list", main), follow = $("#tr-follow", main), search = $("#tr-search", main), count = $("#tr-count", main); let trEls = []; if (list && blocks.length) { const frag = document.createDocumentFragment(); let ci = -1; blocks.forEach((b, i) => { while (ci + 1 < chaps.length && chaps[ci + 1].t <= b[0]) { ci++; const h = document.createElement("div"); h.className = "tr-chap"; h.textContent = chaps[ci].title; frag.appendChild(h); } const pEl = document.createElement("p"); pEl.className = "tr-seg"; pEl.dataset.t = b[0]; pEl.dataset.e = b[1]; pEl.dataset.i = i; pEl.innerHTML = `${hms(b[0])}${esc(b[2])}`; pEl.addEventListener("click", () => seek(b[0])); frag.appendChild(pEl); }); list.innerHTML = ""; list.appendChild(frag); trEls = $$(".tr-seg", list); let userScrolled = false, scrollTimer; const mark = () => { userScrolled = true; clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { userScrolled = false; }, 4000); }; list.parentElement.addEventListener("wheel", mark, { passive: true }); list.parentElement.addEventListener("touchmove", mark, { passive: true }); main._trFollow = () => follow.checked && !userScrolled; const acc = (w) => w.replace(/e/g, "[eéèêë]").replace(/a/g, "[aàâ]").replace(/i/g, "[iîï]").replace(/o/g, "[oôœ]").replace(/u/g, "[uûü]").replace(/c/g, "[cç]"); const runSearch = debounce(() => { const qv = norm(search.value.trim()); if (!qv) { trEls.forEach((el) => { el.classList.remove("hide"); $(".tr-text", el).innerHTML = esc(blocks[el.dataset.i][2]); }); $$(".tr-chap", list).forEach((h) => { h.hidden = false; }); count.textContent = ""; return; } const words = qv.split(/\s+/).filter((w) => w.length > 1).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); const re = new RegExp("(" + words.map(acc).join("|") + ")", "gi"); let n = 0; trEls.forEach((el) => { const txt = blocks[el.dataset.i][2]; const hit = words.every((w) => new RegExp(acc(w), "i").test(txt)); el.classList.toggle("hide", !hit); if (hit) { n++; $(".tr-text", el).innerHTML = esc(txt).replace(re, "$1"); } }); $$(".tr-chap", list).forEach((h) => { h.hidden = true; }); count.textContent = n ? `${n} passage${n > 1 ? "s" : ""} — cliquez pour y aller` : "Aucun passage ne contient ces mots."; }, 120); search?.addEventListener("input", runSearch); } // ---- storyboard const sbHost = $("#vid-sb", main); let sbEls = []; if (sbHost && sb.length) { sbHost.innerHTML = sb.map((f, i) => `
    ${hms(f.t)}
    `).join(""); sbEls = $$("figure", sbHost); sbEls.forEach((f) => f.addEventListener("click", () => seek(Number(f.dataset.t)))); } else if (sbHost) sbHost.closest(".vid-sb-wrap").hidden = true; // ---- questions / déroulé $$(".vq-t[data-t], .vt-seek[data-t]", main).forEach((b) => b.addEventListener("click", () => { seek(Number(b.dataset.t)); player.scrollIntoView({ behavior: "smooth", block: "center" }); })); // ---- barre secondaire const audioBtn = $("[data-vid='audio']", main); audioBtn?.addEventListener("click", () => { const on = audioBtn.getAttribute("aria-pressed") !== "true"; const t = video.currentTime, playing = !video.paused; video.src = on ? video.dataset.audioSrc : video.dataset.videoSrc; video.classList.toggle("audio-only", on); player.classList.toggle("audio-mode", on); audioBtn.setAttribute("aria-pressed", String(on)); video.addEventListener("loadedmetadata", () => { video.currentTime = t; if (playing) video.play().catch(() => {}); }, { once: true }); video.load(); toast(on ? "Audio seulement : la vidéo n’est plus téléchargée." : "Retour à la vidéo."); }); $("[data-vid='link']", main)?.addEventListener("click", async () => { const url = `${location.origin}${location.pathname}?t=${Math.floor(video.currentTime)}`; try { await navigator.clipboard.writeText(url); toast(`Lien copié : ${hms(video.currentTime)}`); } catch (e) { prompt("Copiez ce lien :", url); } }); const resume = $("#vid-resume", main); $("[data-vid='resume']", main)?.addEventListener("click", () => { const st = store.get(key, null); resume.hidden = true; if (st) seek(st.t); }); $("[data-vid='restart']", main)?.addEventListener("click", () => { resume.hidden = true; seek(startT || 0); }); // ---- position de départ : ?t= / #t= / reprise const qs = new URLSearchParams(location.search); const wanted = parseT(qs.get("t") || (location.hash.startsWith("#t=") ? location.hash.slice(3) : "")); const saved = store.get(key, null); const onMeta = () => { $(".vp-time span", ui).textContent = `/ ${hms(dur())}`; seekEl.setAttribute("aria-valuemax", String(Math.round(dur()))); if (!isNaN(wanted)) { seek(wanted, false); player.dataset.state = "paused"; vtoast(`Position : ${hms(wanted)}`); } else if (saved && saved.t > 45 && saved.t < dur() - 60 && !saved.done) { $("#vid-resume-t").textContent = hms(saved.t); resume.hidden = false; } update(); }; if (video.readyState >= 1) onMeta(); else video.addEventListener("loadedmetadata", onMeta, { once: true }); video.addEventListener("play", () => { resume.hidden = true; }); video.addEventListener("progress", () => { const d = dur(); if (!d || !video.buffered.length) return; const end = video.buffered.end(video.buffered.length - 1); segEls.forEach((s) => { const a = Number(s.dataset.t), b = Number(s.dataset.end); $(".buf", s).style.width = `${Math.max(0, Math.min(1, (end - a) / (b - a))) * 100}%`; }); }); // ---- synchronisation let curChap = -1, curSeg = -1, curSb = -1, lastSave = 0; const chapNow = $("#vid-chapnow", main), badge = q("#vp-chap"), title = q("#vp-title"); const update = () => { const t = video.currentTime, d = dur(); $(".vp-time .cur", ui).textContent = hms(t); seekEl.setAttribute("aria-valuenow", String(Math.round(t))); seekEl.setAttribute("aria-valuetext", hms(t)); if (!dragging) knob.style.left = `${d ? t / d * 100 : 0}%`; segEls.forEach((s) => { const a = Number(s.dataset.t), b = Number(s.dataset.end); $(".played", s).style.width = `${(t <= a ? 0 : t >= b ? 1 : (t - a) / (b - a)) * 100}%`; }); if (skipBtn) skipBtn.hidden = !(startT && t < startT - 5); const ci = chapAt(t); if (ci !== curChap) { curChap = ci; const c = chaps[ci]; chapEls.forEach((el, i) => { el.classList.toggle("cur", i === ci); el.classList.toggle("seen", i < ci); }); const el = chapEls[ci]; if (el && !$(".vid-pane[data-pane='chapters']", main).hidden) el.scrollIntoView({ block: "nearest", behavior: "smooth" }); if (c) { badge.hidden = false; badge.innerHTML = `${hms(c.t)}${esc(c.title)}`; title.textContent = c.title; if (chapNow) chapNow.innerHTML = `${hms(c.t)}${esc(c.title)}`; } else { badge.hidden = true; title.textContent = ""; } } if (trEls.length) { let si = -1; for (let i = 0; i < blocks.length; i++) if (blocks[i][0] <= t + .3) si = i; else break; if (si !== curSeg) { curSeg = si; trEls.forEach((el, i) => el.classList.toggle("cur", i === si)); const el = trEls[si]; if (el && main._trFollow() && !$(".vid-pane[data-pane='transcript']", main).hidden && !el.classList.contains("hide")) el.scrollIntoView({ block: "center", behavior: "smooth" }); } } if (sbEls.length) { let bi = -1; for (let i = 0; i < sb.length; i++) if (sb[i].t <= t + .5) bi = i; else break; if (bi !== curSb) { curSb = bi; sbEls.forEach((f, i) => f.classList.toggle("cur", i === bi)); const f = sbEls[bi]; if (f) f.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" }); } } if (!video.paused && Date.now() - lastSave > 4000) { lastSave = Date.now(); save(); } }; const save = () => { const t = video.currentTime, d = dur(); if (!d || (t < 5 && !store.get(key, null))) return; const st = store.get(key, { max: 0 }); st.t = t; st.dur = d; st.max = Math.max(st.max || 0, t); st.done = st.done || st.max / d >= .9; st.at = Date.now(); store.set(key, st); const done = $("[data-video-done]", main); if (done) done.hidden = !st.done; }; video.addEventListener("timeupdate", update); video.addEventListener("seeked", update); video.addEventListener("pause", save); video.addEventListener("ended", () => { const st = store.get(key, {}); st.done = true; store.set(key, st); save(); player.dataset.state = "paused"; toast("Séance visionnée au complet. Bravo !", "ok"); confetti(); }); addEventListener("pagehide", save); if (saved && saved.done) { const done = $("[data-video-done]", main); if (done) done.hidden = false; } // ---- clavier addEventListener("keydown", (e) => { if (e.target.matches("input, textarea, select") || e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === " " || e.key === "k") { e.preventDefault(); toggle(); flash(video.paused ? VI.play : VI.pause); } else if (e.key === "ArrowLeft" || e.key === "j") { e.preventDefault(); seek(video.currentTime - 10, !video.paused); vtoast("−10 s"); } else if (e.key === "ArrowRight" || e.key === "l") { e.preventDefault(); seek(video.currentTime + 10, !video.paused); vtoast("+10 s"); } else if (e.key === "c") ccBtn.click(); else if (e.key === "f") fullscreen(); else if (e.key === "t") theaterBtn.click(); else if (e.key === "m") muteBtn.click(); else if (e.key === "<" || e.key === ">") { const rates = [.75, 1, 1.25, 1.5, 1.75, 2]; const i = rates.indexOf(video.playbackRate); setRate(rates[Math.max(0, Math.min(rates.length - 1, i + (e.key === ">" ? 1 : -1)))]); vtoast(`Vitesse ${rateBtn.textContent}`); } else if (/^[0-9]$/.test(e.key)) { seek(dur() * Number(e.key) / 10, !video.paused); } wake(); }); const kbd = document.createElement("p"); kbd.className = "vid-kbd"; kbd.innerHTML = "espace lecture/pause · ← → ±10 s · c sous-titres · < > vitesse · t mode cinéma · f plein écran · m muet · survolez la barre pour voir la diapositive"; $(".vid-bar", main).after(kbd); } // --------------------------------------------------------------------------- // Démarrage // --------------------------------------------------------------------------- initTheme(); initNav(); initSearch(); initGlossaryTips(); initChapter(); if ($("#quiz-page")) Quiz.mount($("#quiz-page"), {}); initFlashcards(); initTools(); initTraining(); initGames(); initProgressPage(); initMap(); initGlossaryPage(); initSlides(); initSideSpy(); initHashDetails(); renderProgressWidgets(); Widgets.mountAll(); initHPI(); initMacro(); initLightbox(); initVideosIndex(); initVideo(); })();