spb/uqo-imm1003
Public
JavaScript 68%
CSS 32%
1/* =============================================================================2 UQO — Notes de cours interactives — application cliente v2 (sans dépendance)3 Modules : stockage, thème, navigation, recherche, infobulles, séance (lecture,4 sommaire, objectifs, révision, surlignage, notes, audio), widgets visuels,5 quiz, fiches, calculateurs, entraînement, jeux, progression, carte du cours.6 ============================================================================= */7(() => {8 "use strict";910 const body = document.body;11 const COURSE = body.dataset.course || "cours";12 const PREFIX = `uqo:${COURSE}:`;13 const $ = (sel, root = document) => root.querySelector(sel);14 const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));1516 // ---------------------------------------------------------------------------17 // Stockage local18 // ---------------------------------------------------------------------------19 const store = {20 get(key, def) { try { const v = localStorage.getItem(PREFIX + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } },21 set(key, val) { try { localStorage.setItem(PREFIX + key, JSON.stringify(val)); } catch (e) { /* quota */ } },22 getGlobal(key, def) { try { const v = localStorage.getItem("uqo:" + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } },23 setGlobal(key, val) { try { localStorage.setItem("uqo:" + key, JSON.stringify(val)); } catch (e) { /* quota */ } },24 };25 const DEFAULT_PROGRESS = { chapters: {}, quiz: {}, obj: {}, cards: {}, last: null, training: {}, games: {}, hl: [], notes: {}, sections: {} };26 const progress = () => ({ ...DEFAULT_PROGRESS, ...store.get("progress", {}) });27 const saveProgress = (p) => store.set("progress", p);2829 // ---------------------------------------------------------------------------30 // Utilitaires31 // ---------------------------------------------------------------------------32 const nf = (d) => new Intl.NumberFormat("fr-CA", { minimumFractionDigits: d, maximumFractionDigits: d });33 const fmtMoney = (v) => (isFinite(v) ? nf(0).format(Math.round(v)) + " $" : "—");34 const fmtMoney2 = (v) => (isFinite(v) ? nf(2).format(v) + " $" : "—");35 const fmtPct = (v, d = 2) => (isFinite(v) ? nf(d).format(v) + " %" : "—");36 const fmtNum = (v, d = 2) => (isFinite(v) ? new Intl.NumberFormat("fr-CA", { maximumFractionDigits: d }).format(v) : "—");37 const norm = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[’']/g, "'");38 const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));39 const shuffle = (arr, seed) => {40 const a = arr.slice();41 let s = seed || Math.floor(Math.random() * 1e9);42 const rnd = () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; };43 for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }44 return a;45 };46 const fetchJSON = (() => { const cache = {}; return (url) => (cache[url] ||= fetch(url).then((r) => { if (!r.ok) throw new Error(url); return r.json(); })); })();47 const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };48 const parseNumber = (s) => { const t = String(s).replace(/[\s $%]/g, "").replace(/,/g, "."); const v = parseFloat(t); return isFinite(v) ? v : NaN; };49 const R = {50 int: (a, b) => a + Math.floor(Math.random() * (b - a + 1)),51 pick: (arr) => arr[Math.floor(Math.random() * arr.length)],52 step: (a, b, st) => a + st * Math.floor(Math.random() * (Math.floor((b - a) / st) + 1)),53 money: fmtMoney, money2: fmtMoney2, pct: fmtPct, num: fmtNum,54 };5556 function toast(msg, kind = "") {57 let host = $("#toasts");58 if (!host) { host = document.createElement("div"); host.id = "toasts"; host.className = "toasts"; body.appendChild(host); }59 const t = document.createElement("div");60 t.className = "toast " + kind;61 t.innerHTML = msg;62 host.appendChild(t);63 requestAnimationFrame(() => t.classList.add("in"));64 setTimeout(() => { t.classList.remove("in"); setTimeout(() => t.remove(), 300); }, 3600);65 }66 function confetti() {67 const c = document.createElement("canvas");68 c.className = "confetti";69 body.appendChild(c);70 const ctx = c.getContext("2d");71 c.width = innerWidth; c.height = innerHeight;72 const colors = ["#cca424", "#2976bb", "#107c4e", "#ffffff", "#f1d67a"];73 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 }));74 let frame = 0;75 const tick = () => {76 ctx.clearRect(0, 0, c.width, c.height);77 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(); });78 if (frame++ < 130) requestAnimationFrame(tick); else c.remove();79 };80 tick();81 }8283 // ---------------------------------------------------------------------------84 // Thème, taille de police, révélation au défilement85 // ---------------------------------------------------------------------------86 function initTheme() {87 const saved = store.getGlobal("theme", null);88 const prefersDark = matchMedia && matchMedia("(prefers-color-scheme: dark)").matches;89 document.documentElement.dataset.theme = saved || (prefersDark ? "dark" : "light");90 const fs = store.getGlobal("fs", null);91 if (fs) document.documentElement.dataset.fs = fs;92 $$("[data-action='theme']").forEach((b) => b.addEventListener("click", () => {93 const t = document.documentElement.dataset.theme === "dark" ? "light" : "dark";94 document.documentElement.dataset.theme = t;95 store.setGlobal("theme", t);96 }));97 $$("[data-action='fontsize']").forEach((b) => b.addEventListener("click", () => {98 const order = ["s", "", "l", "xl"];99 const cur = document.documentElement.dataset.fs || "";100 const next = order[(order.indexOf(cur) + 1) % order.length];101 if (next) document.documentElement.dataset.fs = next; else delete document.documentElement.dataset.fs;102 store.setGlobal("fs", next);103 toast(`Taille du texte : ${{ s: "petite", "": "normale", l: "grande", xl: "très grande" }[next]}`);104 }));105 $$("[data-action='top']").forEach((b) => b.addEventListener("click", () => scrollTo({ top: 0, behavior: "smooth" })));106 // révélation107 if ("IntersectionObserver" in window && !matchMedia("(prefers-reduced-motion: reduce)").matches) {108 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" });109 $$(".reveal").forEach((el) => io.observe(el));110 } else $$(".reveal").forEach((el) => el.classList.add("in"));111 }112113 // ---------------------------------------------------------------------------114 // Navigation115 // ---------------------------------------------------------------------------116 function initNav() {117 const menuBtn = $(".menu-btn");118 const nav = $(".topnav");119 if (menuBtn && nav) menuBtn.addEventListener("click", () => { const open = nav.classList.toggle("open"); menuBtn.setAttribute("aria-expanded", String(open)); });120 $$(".dd > button").forEach((btn) => {121 const dd = btn.parentElement;122 btn.addEventListener("click", (e) => {123 e.stopPropagation();124 const open = dd.classList.toggle("open");125 btn.setAttribute("aria-expanded", String(open));126 $$(".dd.open").forEach((o) => { if (o !== dd) o.classList.remove("open"); });127 });128 });129 document.addEventListener("click", (e) => { if (!e.target.closest(".dd")) $$(".dd.open").forEach((o) => o.classList.remove("open")); });130 document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".dd.open").forEach((o) => o.classList.remove("open")); });131 const p = progress();132 $$(".dd-menu a[data-ch]").forEach((a) => { const ch = p.chapters[a.dataset.ch]; if (ch && ch.done) a.querySelector(".pastille")?.classList.add("done"); });133 // barre de défilement globale (topbar) : ombre134 const tb = $(".topbar");135 if (tb) addEventListener("scroll", () => tb.classList.toggle("scrolled", scrollY > 8), { passive: true });136 }137138 // ---------------------------------------------------------------------------139 // Recherche140 // ---------------------------------------------------------------------------141 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"]);142 const tokenize = (s) => norm(s).replace(/[^a-z0-9àâçéèêëîïôûùüÿœ\s-]/gi, " ").split(/[\s-]+/).filter((w) => w.length > 1);143 let searchIndex = null;144 async function loadSearch() {145 if (searchIndex) return searchIndex;146 const data = await fetchJSON("/data/search.json");147 const inv = new Map();148 data.forEach((d, i) => {149 const seen = new Set();150 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); });151 d._normText = norm(d.title + " — " + d.text);152 });153 searchIndex = { data, inv };154 return searchIndex;155 }156 function search(query, limit = 30) {157 const { data, inv } = searchIndex;158 const qTokens = tokenize(query).filter((t) => !STOP.has(t));159 if (!qTokens.length) return [];160 const scores = new Map();161 const phrase = norm(query.trim());162 qTokens.forEach((qt) => {163 for (const [tok, ids] of inv) {164 let w = 0;165 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;166 if (!w) continue;167 ids.forEach((i) => scores.set(i, (scores.get(i) || 0) + w / Math.sqrt(ids.length)));168 }169 });170 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 };171 const out = [];172 for (const [i, s] of scores) {173 const d = data[i];174 let sc = s * (KIND_W[d.kind] || 1);175 if (phrase.length > 3 && d._normText.includes(phrase)) sc *= 2.2;176 if (norm(d.title).includes(phrase)) sc *= 1.5;177 out.push({ d, sc });178 }179 out.sort((a, b) => b.sc - a.sc);180 return out.slice(0, limit).map((o) => o.d);181 }182 function snippet(text, query, len = 180) {183 const n = norm(text);184 const q = tokenize(query).filter((t) => !STOP.has(t));185 let pos = -1;186 for (const t of q) { pos = n.indexOf(t); if (pos >= 0) break; }187 if (pos < 0) pos = 0;188 const start = Math.max(0, pos - 60);189 let s = text.slice(start, start + len);190 if (start > 0) s = "…" + s;191 if (start + len < text.length) s += "…";192 const words = q.filter((t) => t.length > 2).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));193 if (!words.length) return esc(s);194 const re = new RegExp("(" + words.join("|") + ")", "gi");195 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ç]");196 return esc(s).replace(new RegExp(pattern, "gi"), "<mark>$1</mark>");197 }198 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" };199 function renderHits(container, hits, query) {200 if (!hits.length) { container.innerHTML = `<div class="search-empty">Aucun résultat pour « ${esc(query)} ».</div>`; return; }201 container.innerHTML = hits.map((d, i) => `<a class="search-hit${i === 0 ? " active" : ""}" href="${d.url}"><div class="sh-title"><span class="sh-kind">${KIND_LABEL[d.kind] || d.kind}</span>${esc(d.title || d.chTitle)}</div><div class="sh-snippet">${snippet(d.text, query)}</div><div class="sh-where">${esc(d.where)}</div></a>`).join("");202 }203 function initSearch() {204 const modal = $("#search-modal");205 if (!modal) return;206 const input = $("input", modal);207 const results = $(".search-results", modal);208 const open = () => { modal.classList.add("open"); input.value = ""; results.innerHTML = '<div class="search-empty">Tapez un terme : définition, formule, notion, exemple…</div>'; setTimeout(() => input.focus(), 30); loadSearch(); };209 const close = () => modal.classList.remove("open");210 $$("[data-action='search']").forEach((b) => b.addEventListener("click", open));211 modal.addEventListener("click", (e) => { if (e.target === modal) close(); });212 document.addEventListener("keydown", (e) => {213 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); modal.classList.contains("open") ? close() : open(); }214 else if (e.key === "/" && !/input|textarea|select/i.test(document.activeElement?.tagName || "") && !modal.classList.contains("open")) { e.preventDefault(); open(); }215 else if (e.key === "Escape" && modal.classList.contains("open")) close();216 });217 const run = debounce(async () => {218 const q = input.value.trim();219 if (q.length < 2) { results.innerHTML = '<div class="search-empty">Tapez au moins deux caractères.</div>'; return; }220 await loadSearch();221 renderHits(results, search(q), q);222 }, 120);223 input.addEventListener("input", run);224 input.addEventListener("keydown", (e) => {225 const hits = $$(".search-hit", results);226 const idx = hits.findIndex((h) => h.classList.contains("active"));227 if (e.key === "ArrowDown" || e.key === "ArrowUp") {228 e.preventDefault();229 if (!hits.length) return;230 const ni = e.key === "ArrowDown" ? Math.min(hits.length - 1, idx + 1) : Math.max(0, idx - 1);231 hits.forEach((h) => h.classList.remove("active"));232 hits[ni].classList.add("active");233 hits[ni].scrollIntoView({ block: "nearest" });234 } else if (e.key === "Enter" && hits[idx]) location.href = hits[idx].href;235 });236 const page = $(".search-page");237 if (page) {238 const pin = $("input", page);239 const pres = $(".search-results", page);240 const doIt = debounce(async () => {241 const q = pin.value.trim();242 history.replaceState(null, "", q ? `?q=${encodeURIComponent(q)}` : location.pathname);243 if (q.length < 2) { pres.innerHTML = ""; return; }244 await loadSearch();245 renderHits(pres, search(q, 60), q);246 }, 150);247 pin.addEventListener("input", doIt);248 const q0 = new URLSearchParams(location.search).get("q");249 if (q0) { pin.value = q0; doIt(); }250 pin.focus();251 }252 }253254 // ---------------------------------------------------------------------------255 // Infobulles glossaire256 // ---------------------------------------------------------------------------257 function initGlossaryTips() {258 const terms = $$(".motcle");259 if (!terms.length) return;260 let gl = null, tip = null;261 const lookup = (t) => {262 const n = norm(t).replace(/[^a-z0-9 ]/g, "").trim();263 if (!n) return null;264 if (gl.byNorm[n]) return gl.byNorm[n];265 const alt = n.endsWith("s") ? n.slice(0, -1) : n + "s";266 if (gl.byNorm[alt]) return gl.byNorm[alt];267 const found = Object.keys(gl.byNorm).find((k) => k.length > 4 && (n.includes(k) || k.includes(n)));268 return found ? gl.byNorm[found] : null;269 };270 const hide = () => { if (tip) { tip.remove(); tip = null; } };271 const show = (el) => {272 const e = lookup(el.dataset.term || el.textContent);273 if (!e) return;274 hide();275 tip = document.createElement("div");276 tip.className = "tip";277 tip.innerHTML = `<b>${esc(e.term)}</b><p>${e.html}</p><a href="/glossaire/#${e.id}">Voir dans le glossaire →</a>`;278 body.appendChild(tip);279 const r = el.getBoundingClientRect();280 let left = r.left + scrollX;281 if (left + tip.offsetWidth > scrollX + innerWidth - 12) left = scrollX + innerWidth - tip.offsetWidth - 12;282 tip.style.left = left + "px";283 tip.style.top = r.bottom + scrollY + 8 + "px";284 };285 terms.forEach((el) => {286 el.addEventListener("mouseenter", async () => { gl ||= await fetchJSON("/data/glossaire.json"); if (el.matches(":hover")) show(el); });287 el.addEventListener("mouseleave", () => setTimeout(() => { if (!tip?.matches(":hover")) hide(); }, 150));288 el.addEventListener("click", async (e) => { e.preventDefault(); gl ||= await fetchJSON("/data/glossaire.json"); tip ? hide() : show(el); });289 });290 document.addEventListener("click", (e) => { if (tip && !e.target.closest(".tip") && !e.target.closest(".motcle")) hide(); });291 document.addEventListener("scroll", () => { if (tip) hide(); }, { passive: true });292 }293294 // ---------------------------------------------------------------------------295 // Page de séance296 // ---------------------------------------------------------------------------297 function initChapter() {298 const main = $(".chapter-page");299 if (!main) return;300 const ch = main.dataset.chapter;301 const p = progress();302 p.chapters[ch] ||= { pct: 0, done: false, visits: 0 };303 const st = p.chapters[ch];304 st.visits = (st.visits || 0) + 1;305 st.visitedAt = Date.now();306 p.last = { ch, title: main.dataset.title, url: location.pathname };307 saveProgress(p);308309 const fill = $(".readbar-fill");310 const tocBar = $(".toc-progress .bar i");311 const tocPct = $(".toc-progress span");312 const article = $(".ch-body");313 const remaining = $("#read-remaining");314 const words = Number(main.dataset.words || 0);315 let maxPct = st.pct || 0;316 const update = () => {317 const rect = article.getBoundingClientRect();318 const total = rect.height - innerHeight * .6;319 const seen = Math.min(Math.max(-rect.top + innerHeight * .4, 0), total);320 const pct = total > 0 ? Math.round((seen / total) * 100) : 100;321 if (fill) fill.style.width = pct + "%";322 if (remaining) { const m = Math.max(0, Math.round(words * (1 - pct / 100) / 180)); remaining.textContent = m > 0 ? `≈ ${m} min restantes` : "Lecture terminée"; }323 if (pct > maxPct) {324 maxPct = pct;325 const pp = progress();326 pp.chapters[ch] = { ...(pp.chapters[ch] || {}), pct: maxPct, done: maxPct >= 92 || (pp.chapters[ch] || {}).done, scroll: scrollY };327 saveProgress(pp);328 }329 if (tocBar) tocBar.style.width = maxPct + "%";330 if (tocPct) tocPct.textContent = maxPct + " % lu";331 };332 let ticking = false;333 addEventListener("scroll", () => { if (!ticking) { requestAnimationFrame(() => { update(); ticking = false; }); ticking = true; } }, { passive: true });334 update();335 addEventListener("scroll", debounce(() => { const pp = progress(); if (pp.chapters[ch]) { pp.chapters[ch].scroll = scrollY; saveProgress(pp); } }, 400), { passive: true });336 if (!location.hash && st.scroll && st.scroll > 400 && new URLSearchParams(location.search).get("reprendre") === "1") setTimeout(() => scrollTo({ top: st.scroll, behavior: "auto" }), 50);337338 // scrollspy sommaire + stepper + sections lues339 const links = $$(".ch-toc li a[href^='#'], .stepper a[href^='#']");340 const heads = $$(".prose h2.h-section");341 const readSections = new Set((p.sections[ch] || []));342 const spy = () => {343 const y = scrollY + 130;344 let cur = null;345 heads.forEach((h, i) => { if (h.offsetTop <= y) cur = h; });346 $$(".ch-toc li a, .stepper a").forEach((a) => a.classList.remove("active"));347 const allH = $$(".prose h2, .prose h3");348 let curAny = allH[0];349 for (const h of allH) { if (h.offsetTop <= y) curAny = h; else break; }350 if (curAny) $$(`.ch-toc li a[href="#${curAny.id}"]`).forEach((a) => a.classList.add("active"));351 if (cur) {352 $$(`.stepper a[href="#${cur.id}"]`).forEach((a) => a.classList.add("active"));353 heads.forEach((h) => { if (h !== cur && h.offsetTop + 200 < y) readSections.add(h.id); });354 $$(".stepper a").forEach((a) => a.classList.toggle("done", readSections.has(a.getAttribute("href").slice(1))));355 $$(".ch-toc li.lvl2 a").forEach((a) => a.classList.toggle("done", readSections.has(a.getAttribute("href").slice(1))));356 }357 };358 addEventListener("scroll", debounce(() => { spy(); const pp = progress(); pp.sections[ch] = [...readSections]; saveProgress(pp); }, 80), { passive: true });359 spy();360361 // objectifs362 const objs = $$(".obj-cb");363 const objProg = $(".obj-progress");364 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"); };365 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"); }); });366 refreshObj();367368 // mode révision369 const revBtns = $$("[data-action='revision']");370 const setRev = (on) => { body.classList.toggle("revision", on); revBtns.forEach((b) => b.setAttribute("aria-pressed", String(on))); store.setGlobal("revision", on); };371 revBtns.forEach((b) => b.addEventListener("click", () => setRev(!body.classList.contains("revision"))));372 $("[data-action='revision-off']")?.addEventListener("click", () => setRev(false));373 if (store.getGlobal("revision", false)) setRev(true);374375 $("[data-action='toggle-solutions']")?.addEventListener("click", (e) => {376 const dets = $$("details.box-sol");377 const anyClosed = dets.some((d) => !d.open);378 dets.forEach((d) => (d.open = anyClosed));379 e.currentTarget.textContent = anyClosed ? "Replier les solutions" : "Révéler toutes les solutions";380 });381382 document.addEventListener("keydown", (e) => {383 if (/input|textarea|select/i.test(document.activeElement?.tagName || "") || e.metaKey || e.ctrlKey || e.altKey) return;384 if (e.key === "ArrowRight") { const n = $(".ch-pager a.next"); if (n) location.href = n.href; }385 if (e.key === "ArrowLeft") { const n = $(".ch-pager a.prev"); if (n) location.href = n.href; }386 });387388 initHighlights(ch);389 initNotes(ch);390 initTTS();391 Widgets.mountAll();392 const qa = $("#quiz-app");393 if (qa) Quiz.mount(qa, { chapter: ch, embedded: true });394 }395396 // ---------------------------------------------------------------------------397 // Surlignage personnel398 // ---------------------------------------------------------------------------399 function initHighlights(ch) {400 const prose = $(".prose");401 if (!prose) return;402 let pop = null;403 const applyAll = () => {404 $$("mark.hl", prose).forEach((m) => { const t = document.createTextNode(m.textContent); m.replaceWith(t); });405 prose.normalize();406 const p = progress();407 p.hl.filter((h) => h.ch === ch).forEach((h) => {408 const para = document.getElementById(h.pid);409 if (!para) return;410 const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);411 const nodes = [];412 let n;413 while ((n = walker.nextNode())) nodes.push(n);414 const full = nodes.map((x) => x.data).join("");415 const idx = full.indexOf(h.text);416 if (idx < 0) return;417 let pos = 0;418 const end = idx + h.text.length;419 nodes.forEach((node) => {420 const s = pos, e = pos + node.data.length;421 pos = e;422 const a = Math.max(idx, s), b = Math.min(end, e);423 if (a >= b) return;424 const range = document.createRange();425 range.setStart(node, a - s);426 range.setEnd(node, b - s);427 const mark = document.createElement("mark");428 mark.className = "hl" + (h.note ? " has-note" : "");429 mark.dataset.hl = h.id;430 if (h.note) mark.title = h.note;431 try { range.surroundContents(mark); } catch (err) { /* chevauchement */ }432 });433 });434 renderNotesList();435 };436 const hidePop = () => { if (pop) { pop.remove(); pop = null; } };437 prose.addEventListener("mouseup", () => setTimeout(() => {438 const sel = getSelection();439 if (!sel || sel.isCollapsed || !sel.toString().trim()) { hidePop(); return; }440 const range = sel.getRangeAt(0);441 const para = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer.closest("p[id], li") : range.commonAncestorContainer.parentElement.closest("p[id], li");442 if (!para || !prose.contains(para)) { hidePop(); return; }443 const pid = para.id || para.closest("p[id], [id]")?.id;444 if (!pid) { hidePop(); return; }445 const text = sel.toString();446 hidePop();447 pop = document.createElement("div");448 pop.className = "hl-pop";449 pop.innerHTML = `<button type="button" data-hl>Surligner</button><button type="button" data-note>Surligner + note</button>`;450 body.appendChild(pop);451 const r = range.getBoundingClientRect();452 pop.style.left = Math.max(8, Math.min(r.left + scrollX + r.width / 2 - pop.offsetWidth / 2, scrollX + innerWidth - pop.offsetWidth - 8)) + "px";453 pop.style.top = r.top + scrollY - pop.offsetHeight - 10 + "px";454 const save = (note) => {455 const p = progress();456 p.hl.push({ id: "h" + Date.now(), ch, pid, text, note: note || "", at: Date.now() });457 saveProgress(p);458 sel.removeAllRanges();459 hidePop();460 applyAll();461 toast("Passage surligné" + (note ? " avec une note." : "."), "ok");462 };463 $("[data-hl]", pop).addEventListener("click", () => save(""));464 $("[data-note]", pop).addEventListener("click", () => { const note = prompt("Votre note sur ce passage :"); if (note !== null) save(note); });465 }, 10));466 document.addEventListener("mousedown", (e) => { if (pop && !e.target.closest(".hl-pop")) hidePop(); });467 prose.addEventListener("click", (e) => {468 const m = e.target.closest("mark.hl");469 if (!m) return;470 const p = progress();471 const h = p.hl.find((x) => x.id === m.dataset.hl);472 if (!h) return;473 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 || "");474 if (action === null) return;475 if (action.trim().toUpperCase() === "SUPPRIMER") p.hl = p.hl.filter((x) => x.id !== h.id); else h.note = action;476 saveProgress(p);477 applyAll();478 });479 applyAll();480 }481482 // ---------------------------------------------------------------------------483 // Notes personnelles (panneau latéral)484 // ---------------------------------------------------------------------------485 function renderNotesList() {486 const list = $("#hl-list");487 if (!list) return;488 const ch = $(".chapter-page")?.dataset.chapter;489 const p = progress();490 const items = p.hl.filter((h) => h.ch === ch);491 list.innerHTML = items.length ? items.map((h) => `<li><a href="#${h.pid}">« ${esc(h.text.slice(0, 90))}${h.text.length > 90 ? "…" : ""} »</a>${h.note ? `<div class="hl-note">${esc(h.note)}</div>` : ""}<button type="button" class="hl-del" data-del="${h.id}" aria-label="Retirer">✕</button></li>`).join("") : '<li class="muted">Sélectionnez un passage du texte pour le surligner ; il apparaîtra ici.</li>';492 $$("[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(); }));493 }494 function initNotes(ch) {495 const panel = $("#notes-panel");496 if (!panel) return;497 const ta = $("textarea", panel);498 const p = progress();499 ta.value = p.notes[ch] || "";500 ta.addEventListener("input", debounce(() => { const pp = progress(); pp.notes[ch] = ta.value; saveProgress(pp); $("#notes-saved").textContent = "Enregistré"; setTimeout(() => ($("#notes-saved").textContent = ""), 1500); }, 400));501 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))); };502 $$("[data-action='notes']").forEach((b) => b.addEventListener("click", () => toggle(!panel.classList.contains("open"))));503 $("[data-action='notes-close']", panel)?.addEventListener("click", () => toggle(false));504 $("[data-action='notes-export']", panel)?.addEventListener("click", () => {505 const pp = progress();506 const title = $(".chapter-page").dataset.title;507 const hl = pp.hl.filter((h) => h.ch === ch);508 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";509 const a = document.createElement("a");510 a.href = URL.createObjectURL(new Blob([md], { type: "text/markdown" }));511 a.download = `${COURSE}-seance-${ch}-notes.md`;512 a.click();513 });514 renderNotesList();515 }516517 // ---------------------------------------------------------------------------518 // Lecture audio (synthèse vocale)519 // ---------------------------------------------------------------------------520 function initTTS() {521 const btn = $("[data-action='tts']");522 if (!btn) return;523 if (!("speechSynthesis" in window)) { btn.disabled = true; btn.title = "Synthèse vocale non disponible"; return; }524 const bar = $("#tts-bar");525 const items = $$(".prose h2, .prose h3, .prose p, .prose li").filter((el) => !el.closest(".sidenote") && el.textContent.trim().length > 2);526 let i = 0, playing = false, utter = null;527 const voice = () => { const vs = speechSynthesis.getVoices(); return vs.find((v) => v.lang === "fr-CA") || vs.find((v) => v.lang.startsWith("fr")) || null; };528 const clear = () => $$(".tts-current").forEach((e) => e.classList.remove("tts-current"));529 const speak = () => {530 if (i >= items.length) { stop(); toast("Lecture terminée."); return; }531 const el = items[i];532 clear();533 el.classList.add("tts-current");534 el.scrollIntoView({ block: "center", behavior: "smooth" });535 utter = new SpeechSynthesisUtterance(el.textContent.replace(/\s+/g, " ").trim());536 const v = voice();537 if (v) utter.voice = v;538 utter.lang = "fr-CA";539 utter.rate = Number($("#tts-rate")?.value || 1);540 utter.onend = () => { if (playing) { i++; speak(); } };541 speechSynthesis.speak(utter);542 };543 const start = () => {544 // départ : premier élément visible545 const y = scrollY + 140;546 const idx = items.findIndex((el) => el.offsetTop >= y);547 i = idx >= 0 ? idx : 0;548 playing = true;549 bar.hidden = false;550 $("#tts-play").textContent = "⏸";551 speak();552 };553 const stop = () => { playing = false; speechSynthesis.cancel(); clear(); bar.hidden = true; $("#tts-play").textContent = "▶"; };554 btn.addEventListener("click", () => (playing ? stop() : start()));555 $("#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(); });556 $("#tts-stop")?.addEventListener("click", stop);557 $("#tts-next")?.addEventListener("click", () => { if (!playing) return; speechSynthesis.cancel(); i++; speak(); });558 $("#tts-prev")?.addEventListener("click", () => { if (!playing) return; speechSynthesis.cancel(); i = Math.max(0, i - 1); speak(); });559 $("#tts-rate")?.addEventListener("change", () => { if (playing) { speechSynthesis.cancel(); speak(); } });560 addEventListener("beforeunload", () => speechSynthesis.cancel());561 }562563 // ---------------------------------------------------------------------------564 // Widgets visuels (SVG) insérés dans les séances565 // ---------------------------------------------------------------------------566 const SVGNS = "http://www.w3.org/2000/svg";567 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; };568 function chart(w, h) {569 const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, class: "wchart", role: "img" });570 const add = (tag, attrs, text) => { const el = svgEl(tag, attrs, text); svg.appendChild(el); return el; };571 return { svg, add, w, h };572 }573 const ctrl = (c) => c.type === "select"574 ? `<label class="wctl"><span>${c.label}</span><select name="${c.key}">${c.options.map((o) => `<option value="${o.value}"${o.value === c.default ? " selected" : ""}>${o.label}</option>`).join("")}</select></label>`575 : `<label class="wctl"><span>${c.label} <output>${c.fmt ? c.fmt(c.default) : c.default}${c.unit ? " " + c.unit : ""}</output></span><input type="range" name="${c.key}" min="${c.min}" max="${c.max}" step="${c.step || 1}" value="${c.default}"></label>`;576577 const Widgets = {578 defs: {},579 register(id, def) { this.defs[id] = def; },580 mountAll() { $$(".widget[data-widget]").forEach((el) => this.mount(el)); },581 mount(el) {582 const def = this.defs[el.dataset.widget];583 if (!def) { el.remove(); return; }584 el.innerHTML = `<div class="w-head"><span class="w-badge">Explorateur interactif</span><h4>${def.title}</h4><p>${def.desc}</p></div><div class="w-body"><form class="w-controls">${def.controls.map(ctrl).join("")}</form><div class="w-view"></div></div>${def.note ? `<p class="w-note">${def.note}</p>` : ""}`;585 const form = $("form", el), view = $(".w-view", el);586 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; };587 const render = () => { view.innerHTML = ""; def.render(view, values(), el); };588 form.addEventListener("input", render);589 render();590 },591 };592 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" };593 function bars(view, rows, opts = {}) {594 // rows : [{label, value, color, note}] barres horizontales proportionnelles595 const max = opts.max || Math.max(...rows.map((r) => Math.abs(r.value)), 1);596 view.insertAdjacentHTML("beforeend", `<div class="wbars">${rows.map((r) => `<div class="wbar"><span class="wbar-l">${r.label}</span><span class="wbar-track"><i style="width:${Math.min(100, Math.abs(r.value) / max * 100)}%;background:${r.color || W_CO.bleu}"></i></span><b class="wbar-v">${r.fmt ? r.fmt(r.value) : fmtMoney(r.value)}</b></div>`).join("")}</div>`);597 }598 function kpis(view, list) {599 view.insertAdjacentHTML("beforeend", `<div class="wkpis">${list.map((k) => `<div class="wkpi ${k.cls || ""}"><span>${k.label}</span><b>${k.value}</b></div>`).join("")}</div>`);600 }601 function lineChart(view, series, opts) {602 // series: [{points:[[x,y]], color, label}], opts: {xmin,xmax,ymin,ymax,xfmt,yfmt,marker:[x,y], xlabel, ylabel, w, h}603 const w = opts.w || 560, h = opts.h || 260, m = { l: 62, r: 16, t: 14, b: 40 };604 const c = chart(w, h);605 const X = (x) => m.l + (x - opts.xmin) / (opts.xmax - opts.xmin) * (w - m.l - m.r);606 const Y = (y) => h - m.b - (y - opts.ymin) / (opts.ymax - opts.ymin) * (h - m.t - m.b);607 for (let i = 0; i <= 4; i++) {608 const yv = opts.ymin + (opts.ymax - opts.ymin) * i / 4;609 c.add("line", { x1: m.l, x2: w - m.r, y1: Y(yv), y2: Y(yv), class: "grid" });610 c.add("text", { x: m.l - 8, y: Y(yv) + 4, class: "tick", "text-anchor": "end" }, opts.yfmt ? opts.yfmt(yv) : fmtNum(yv, 0));611 const xv = opts.xmin + (opts.xmax - opts.xmin) * i / 4;612 c.add("text", { x: X(xv), y: h - m.b + 18, class: "tick", "text-anchor": "middle" }, opts.xfmt ? opts.xfmt(xv) : fmtNum(xv, 1));613 }614 c.add("line", { x1: m.l, x2: w - m.r, y1: Y(opts.ymin), y2: Y(opts.ymin), class: "axis" });615 c.add("line", { x1: m.l, x2: m.l, y1: Y(opts.ymin), y2: Y(opts.ymax), class: "axis" });616 if (opts.xlabel) c.add("text", { x: (m.l + w - m.r) / 2, y: h - 6, class: "alabel", "text-anchor": "middle" }, opts.xlabel);617 if (opts.ylabel) c.add("text", { x: 12, y: m.t + 4, class: "alabel" }, opts.ylabel);618 series.forEach((s) => {619 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(" ");620 c.add("path", { d, fill: "none", stroke: s.color || W_CO.bleu, "stroke-width": 2.5, "stroke-linejoin": "round" });621 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);622 });623 (opts.markers || []).forEach((mk) => {624 c.add("line", { x1: X(mk[0]), x2: X(mk[0]), y1: Y(opts.ymin), y2: Y(mk[1]), class: "mline" });625 c.add("line", { x1: m.l, x2: X(mk[0]), y1: Y(mk[1]), y2: Y(mk[1]), class: "mline" });626 c.add("circle", { cx: X(mk[0]), cy: Y(mk[1]), r: 6, fill: W_CO.or, stroke: "#fff", "stroke-width": 2 });627 if (mk[2]) c.add("text", { x: X(mk[0]) + 10, y: Y(mk[1]) - 10, class: "mlabel" }, mk[2]);628 });629 view.appendChild(c.svg);630 return { X, Y, c };631 }632 function stackBar(view, segments, total, opts = {}) {633 // segments: [{label, value, color}] — barre empilée horizontale unique + légende634 const sum = total || segments.reduce((a, s) => a + Math.max(0, s.value), 0);635 view.insertAdjacentHTML("beforeend", `<div class="wstack"><div class="wstack-bar">${segments.map((s) => `<i style="width:${Math.max(0, s.value) / sum * 100}%;background:${s.color}" title="${s.label} : ${fmtMoney(s.value)}"></i>`).join("")}</div><div class="wstack-legend">${segments.map((s) => `<span><i style="background:${s.color}"></i>${s.label} <b>${opts.fmt ? opts.fmt(s.value) : fmtMoney(s.value)}</b> <small>(${fmtPct(Math.max(0, s.value) / sum * 100, 1)})</small></span>`).join("")}</div></div>`);636 }637 function waterfall(view, steps, opts = {}) {638 // steps: [{label, delta, color, total?}] → SVG cascade639 const w = 600, h = 280, m = { l: 20, r: 20, t: 20, b: 70 };640 const c = chart(w, h);641 let running = 0;642 const totals = [];643 steps.forEach((s) => { running = s.total !== undefined ? s.total : running + s.delta; totals.push(running); });644 const max = Math.max(...totals, ...steps.map((s) => Math.abs(s.delta))) * 1.08;645 const bw = (w - m.l - m.r) / steps.length;646 const Y = (v) => h - m.b - v / max * (h - m.t - m.b);647 let prev = 0;648 steps.forEach((s, i) => {649 const x = m.l + i * bw + bw * .12;650 const cur = totals[i];651 let y0, y1;652 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); }653 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) });654 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));655 const lbl = s.label.split("\n");656 lbl.forEach((l, k) => c.add("text", { x: x + bw * .38, y: h - m.b + 18 + k * 14, class: "tick", "text-anchor": "middle" }, l));657 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" });658 prev = cur;659 });660 c.add("line", { x1: m.l, x2: w - m.r, y1: Y(0), y2: Y(0), class: "axis" });661 view.appendChild(c.svg);662 }663664 // --- définitions des widgets ------------------------------------------------665 Widgets.register("rpv", {666 title: "Prix, évaluation et mise de fonds",667 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.",668 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: "%" }],669 render(view, v) {670 const ret = Math.min(v.prix, v.eval), pret = ret * v.rpv / 100, mise = v.prix - pret;671 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);672 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" }]);673 },674 });675 Widgets.register("mutation", {676 title: "Les tranches des droits de mutation",677 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.",678 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: "%" }],679 render(view, v) {680 const b = v.prix;681 const t1 = Math.min(b, 62900) * .005, t2 = Math.max(0, Math.min(b, 315000) - 62900) * .01;682 let t3, t4 = 0;683 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;684 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 }];685 if (t4) segs.push({ label: `${v.muni} % municipal (> 500 000 $)`, value: t4, color: W_CO.rouge });686 stackBar(view, segs, null, { fmt: fmtMoney2 });687 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) }]);688 },689 });690 Widgets.register("offre-demande", {691 title: "Offre inélastique et choc de demande",692 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.",693 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: "%" }],694 render(view, v) {695 const P = (q) => Math.max(0, 900 - 6 * q); // demande de base696 const dem = (q) => P(q) * (1 + v.dem / 100);697 const supp = (q) => 60 + (q - v.off) * (60 / v.elas);698 // équilibre : dem(q) = supp(q)699 let qe = 0; for (let q = 0; q <= 150; q += .25) { if (dem(q) <= supp(q)) { qe = q; break; } }700 const pe = supp(qe);701 const q0 = (() => { let q = 0; for (let x = 0; x <= 150; x += .25) { if (P(x) <= 60 + x * 30) { q = x; break; } } return q; })();702 const p0 = 60 + q0 * 30;703 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" }],704 { 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$`]] });705 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) }]);706 },707 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.",708 });709 Widgets.register("cycle", {710 title: "Le cycle immobilier et ses indicateurs",711 desc: "Parcourez le cycle : à chaque phase correspondent des indicateurs typiques (mois d’inventaire, délai de vente, ratio vendu/demandé, mises en chantier).",712 controls: [{ key: "t", label: "Position dans le cycle", min: 0, max: 100, step: 1, default: 20, unit: "%" }],713 render(view, v) {714 const phases = [715 { 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"] },716 { 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"] },717 { 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"] },718 { 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"] },719 ];720 const ph = phases.find((p) => v.t >= p.from && v.t < p.to) || phases[3];721 const pts = Array.from({ length: 101 }, (_, i) => [i, 50 + 40 * Math.sin((i / 100) * 2 * Math.PI - Math.PI / 2)]);722 const cur = 50 + 40 * Math.sin((v.t / 100) * 2 * Math.PI - Math.PI / 2);723 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]] });724 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)" }));725 view.insertAdjacentHTML("beforeend", `<div class="wphase"><h5>${ph.n}</h5><p>${ph.txt}</p><ul>${ph.ind.map((x) => `<li>${x}</li>`).join("")}</ul></div>`);726 },727 });728 Widgets.register("seuils", {729 title: "Ajustements net et brut : les seuils de prudence",730 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 %.",731 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 }],732 render(view, v) {733 const adj = [v.a, v.b, v.c, v.d];734 const net = adj.reduce((s, x) => s + x, 0), brut = adj.reduce((s, x) => s + Math.abs(x), 0);735 const np = Math.abs(net) / v.prix * 100, bp = brut / v.prix * 100;736 view.insertAdjacentHTML("beforeend", `<div class="wgauges">${[["Ajustement net", np, 15], ["Ajustement brut", bp, 25]].map(([l, val, seuil]) => `<div class="wgauge"><div class="wg-head"><span>${l}</span><b class="${val > seuil ? "warn" : "good"}">${fmtPct(val, 1)}</b></div><div class="wg-track"><i style="width:${Math.min(100, val / (seuil * 2) * 100)}%;background:${val > seuil ? W_CO.rouge : W_CO.vert}"></i><span class="wg-seuil" style="left:50%" title="Seuil ${seuil} %"></span></div><small>seuil ${seuil} %</small></div>`).join("")}</div>`);737 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" }]);738 },739 });740 Widgets.register("age-vie", {741 title: "Dépréciation selon l’âge effectif",742 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.",743 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" }],744 render(view, v) {745 const base = v.cn - v.cur;746 const D = (a) => (v.cur + base * Math.min(1, a / v.vie)) / v.cn * 100;747 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)]] });748 const inc = base * Math.min(1, v.age / v.vie);749 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" }]);750 },751 });752 Widgets.register("cascade-revenus", {753 title: "La cascade des revenus, du RBP à la valeur",754 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.",755 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: "%" }],756 render(view, v) {757 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;758 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 }]);759 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" }]);760 },761 });762 Widgets.register("sensibilite-tga", {763 title: "La valeur réagit au taux global d’actualisation",764 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é.",765 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: "%" }],766 render(view, v) {767 const V = (t) => v.rne / (t / 100);768 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))]] });769 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) }]);770 },771 });772 Widgets.register("dcf", {773 title: "Anatomie d’un DCF : flux actualisés et réversion",774 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.",775 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: "%" }],776 render(view, v) {777 const r = v.r / 100, g = v.g / 100;778 let rne = v.rne, pv = 0;779 const rows = [];780 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; }781 const rev = rne / (v.tgaout / 100) * .97, pvRev = rev / Math.pow(1 + r, v.n);782 rows.push({ label: "Réversion (nette 3 %)", value: pvRev, color: W_CO.or });783 bars(view, rows);784 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" : "" }]);785 },786 });787 Widgets.register("cascade-cout", {788 title: "Des coûts directs au coût neuf total",789 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 %.",790 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)" }],791 render(view, v) {792 const i = v.d * v.ind / 100, p = (v.d + i) * v.prof / 100;793 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 }]);794 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) }]);795 },796 });797 Widgets.register("hbu", {798 title: "Le filtre du plus haut et meilleur usage",799 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.",800 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)" }] }],801 render(view, v) {802 const uses = [803 { n: "Unifamiliale", legal: ["res", "mixte"], phys: true, resid: 120000, coutSol: 0 },804 { n: "Triplex", legal: ["res", "mixte"], phys: true, resid: 165000, coutSol: 15000 },805 { n: "Immeuble de 6 logements", legal: ["mixte"], phys: true, resid: 240000, coutSol: 45000 },806 { n: "Commerce de détail + logements", legal: ["mixte"], phys: true, resid: 290000, coutSol: 60000 },807 { n: "Entrepôt", legal: ["ind"], phys: true, resid: 130000, coutSol: 30000 },808 { n: "Tour de 12 étages", legal: ["mixte"], phys: false, resid: 900000, coutSol: 400000 },809 ];810 const rows = uses.map((u) => {811 const legal = u.legal.includes(v.zon);812 const phys = u.phys && !(v.sol === "argile" && u.n.includes("Tour"));813 const resid = u.resid * v.marche / 100 - (v.sol === "argile" ? u.coutSol : 0);814 const fin = resid > 0;815 return { ...u, legal, phys, fin, resid, ok: legal && phys && fin };816 });817 const best = rows.filter((r) => r.ok).sort((a, b) => b.resid - a.resid)[0];818 view.insertAdjacentHTML("beforeend", `<table class="wtable"><thead><tr><th>Usage candidat</th><th>Légal</th><th>Physique</th><th>Financier</th><th>Valeur résiduelle du terrain</th></tr></thead><tbody>${rows.map((r) => `<tr class="${r.ok ? (r === best ? "best" : "") : "out"}"><td>${r.n}${r === best ? ' <span class="chip chip-vert">PHMU</span>' : ""}</td><td>${r.legal ? "✓" : "✗"}</td><td>${r.legal ? (r.phys ? "✓" : "✗") : "—"}</td><td>${r.legal && r.phys ? (r.fin ? "✓" : "✗") : "—"}</td><td>${r.legal && r.phys ? fmtMoney(r.resid) : "—"}</td></tr>`).join("")}</tbody></table>`);819 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) : "—" }]);820 },821 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.",822 });823 Widgets.register("harper", {824 title: "La règle 4-3-2-1 de Harper",825 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.",826 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" }] }],827 render(view, v) {828 const parts = v.regle === "d" ? [.5, .25, .15, .1] : [.4, .3, .2, .1];829 const q = v.pstd / 4;830 let ratio = 0;831 const w = 560, h = 230, c = chart(w, h);832 const scale = (w - 80) / Math.max(v.pstd, v.p);833 for (let i = 0; i < 4; i++) {834 const cov = Math.max(0, Math.min(q, v.p - i * q)) / q;835 ratio += cov * parts[i];836 const x = 40 + i * q * scale;837 c.add("rect", { x, y: 40, width: q * scale - 2, height: 110, rx: 3, fill: `rgba(41,118,187,${.15 + parts[i] * 1.4})` });838 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 });839 c.add("text", { x: x + q * scale / 2, y: 100, class: "vlabel", "text-anchor": "middle" }, fmtPct(parts[i] * 100, 0));840 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`);841 }842 c.add("rect", { x: 20, y: 30, width: 16, height: 130, fill: W_CO.gris, opacity: .5 });843 c.add("text", { x: 28, y: 190, class: "tick", "text-anchor": "middle" }, "rue");844 const extra = v.p > v.pstd ? Math.min(2, (v.p - v.pstd) / q) * .05 : 0;845 ratio += extra;846 view.appendChild(c.svg);847 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) }]);848 },849 note: "Au-delà de la profondeur standard, le surplus est valorisé prudemment (5 % par quart supplémentaire, maximum deux).",850 });851 Widgets.register("indexation", {852 title: "Indexer un coût avec l’IPCB",853 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.",854 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 }],855 render(view, v) {856 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 };857 const f = idx[v.y1] / idx[v.y0];858 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)]] });859 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" }]);860 },861 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.",862 });863 Widgets.register("ventilation", {864 title: "Ventiler la dépréciation physique",865 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.",866 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" }],867 render(view, v) {868 const ct = Math.min(v.ctc, v.cn - v.cur) * v.ctr / 100;869 const base = Math.max(0, v.cn - v.cur - Math.min(v.ctc, v.cn - v.cur));870 const lt = base * Math.min(1, v.age / v.vie);871 const reste = v.cn - v.cur - ct - lt;872 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);873 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)) }]);874 },875 });876 Widgets.register("allocation", {877 title: "Allouer la dépréciation externe",878 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.",879 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 }],880 render(view, v) {881 const d = v.perte / (v.tga / 100), ra = v.vam / (v.vam + v.vt);882 const w = 560, h = 220, c = chart(w, h), cx = 120, cy = 110, r = 80;883 const a = ra * 2 * Math.PI;884 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 }); };885 arc(-Math.PI / 2, -Math.PI / 2 + a, W_CO.bleu);886 arc(-Math.PI / 2 + a, Math.PI * 1.5, W_CO.or);887 c.add("circle", { cx, cy, r: 46, fill: "var(--card)" });888 c.add("text", { x: cx, y: cy + 5, class: "vlabel", "text-anchor": "middle" }, fmtPct(ra * 100, 0));889 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é`);890 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`);891 view.appendChild(c.svg);892 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) }]);893 },894 });895 Widgets.register("bail-vie", {896 title: "Améliorations locatives : le bail limite la vie utile",897 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.",898 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" }],899 render(view, v) {900 const vie = Math.min(v.vph, v.age + v.bail);901 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" }];902 bars(view, rows, { max: Math.max(v.vph, v.age + v.bail) });903 const dep = Math.min(1, v.age / vie);904 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" }]);905 },906 });907908 // ---------------------------------------------------------------------------909 // Quiz910 // ---------------------------------------------------------------------------911 const Quiz = {912 data: null,913 async load() { this.data ||= await fetchJSON("/data/quiz.json"); return this.data; },914 async mount(root, opts) {915 const data = await this.load();916 if (opts.chapter) {917 const qs = data.chapters[opts.chapter] || [];918 if (!qs.length) { root.closest(".ch-quiz")?.remove(); return; }919 this.run(root, qs, { title: `Testez-vous — séance ${Number(opts.chapter)}`, key: opts.chapter, embedded: opts.embedded });920 return;921 }922 this.menu(root, data);923 },924 menu(root, data) {925 const p = progress();926 const items = Object.keys(data.chapters).sort().map((k) => {927 const best = p.quiz[k]?.best, n = data.chapters[k].length;928 return `<button data-ch="${k}"><b>Séance ${Number(k)}</b><small>${esc(data.titles[k] || "")}</small><small>${n} questions</small>${best !== undefined ? `<span class="best">Meilleur score : ${best}/${n}</span>` : ""}</button>`;929 }).join("");930 const total = Object.values(data.chapters).reduce((a, b) => a + b.length, 0);931 const bestExam = p.quiz.exam?.best;932 root.innerHTML = `<div class="quiz-select">933 <button class="exam" data-exam="1"><b>Examen blanc</b><small>25 questions tirées de toutes les séances · 30 minutes</small>${bestExam !== undefined ? `<span class="best" style="color:var(--or)">Meilleur : ${bestExam}/25</span>` : ""}</button>934 <button class="exam" data-exam="bloc1"><b>Révision première moitié</b><small>Séances 1 à 7 · 20 questions</small></button>935 <button class="exam" data-exam="bloc2"><b>Révision seconde moitié</b><small>Séances 8 à 14 · 20 questions</small></button>936 ${items}</div><p class="gl-count">${total} questions au total, avec rétroaction et renvoi vers la section des notes.</p>`;937 $$("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) }); }));938 $$("button[data-exam]", root).forEach((b) => b.addEventListener("click", () => {939 const mode = b.dataset.exam;940 let pool = [];941 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 }))); });942 const qs = shuffle(pool).slice(0, mode === "1" ? 25 : 20);943 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) });944 }));945 },946 run(root, questions, opts) {947 const qs = questions.map((q) => ({ ...q, order: shuffle(q.c.map((_, i) => i)), answer: null }));948 let i = 0, score = 0, timerId = null, remaining = opts.timer || 0;949 const letters = "ABCDEF";950 const fmtT = () => `${String(Math.floor(remaining / 60)).padStart(2, "0")}:${String(remaining % 60).padStart(2, "0")}`;951 const stopTimer = () => { if (timerId) clearInterval(timerId); timerId = null; };952 const render = () => {953 const q = qs[i];954 root.innerHTML = `<div class="quiz">955 <div class="quiz-head">${opts.embedded ? `<h2>${esc(opts.title)}</h2>` : `<h3>${esc(opts.title)}</h3>`}<div class="quiz-score"><span>Question ${i + 1}/${qs.length}</span><span>Score : <b>${score}</b></span>${opts.timer ? `<span class="timer" data-timer>${fmtT()}</span>` : ""}</div></div>956 <div class="quiz-progress"><i style="width:${(i / qs.length) * 100}%"></i></div>957 <div class="q-card"><div class="q-num">${q.ch ? `Séance ${Number(q.ch)} · ` : ""}Question ${i + 1}</div><div class="q-text">${q.q}</div>958 <div class="q-choices">${q.order.map((ci, k) => `<button class="q-choice" data-ci="${ci}"><span class="q-letter">${letters[k]}</span><span>${q.c[ci]}</span></button>`).join("")}</div>959 <div class="q-feedback"></div>960 <div class="q-actions"><button class="btn btn-sm" data-next disabled>${i + 1 < qs.length ? "Question suivante →" : "Voir le résultat"}</button>${opts.back ? `<button class="btn btn-sm btn-ghost" data-back>Quitter</button>` : ""}${q.url ? `<a class="q-link" href="${q.url}">Relire la section →</a>` : ""}</div>961 </div></div>`;962 const fb = $(".q-feedback", root), next = $("[data-next]", root);963 $$(".q-choice", root).forEach((btn) => btn.addEventListener("click", () => {964 if (q.answer !== null) return;965 const ci = Number(btn.dataset.ci);966 q.answer = ci;967 const ok = ci === q.a;968 if (ok) score++;969 $$(".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"); });970 fb.className = "q-feedback show " + (ok ? "ok" : "ko");971 fb.innerHTML = `<b>${ok ? "Bonne réponse." : "Réponse inexacte."}</b>${q.e || ""}`;972 next.disabled = false;973 $(".quiz-score b", root).textContent = score;974 next.focus();975 }));976 next.addEventListener("click", () => { i++; if (i < qs.length) render(); else finish(); });977 $("[data-back]", root)?.addEventListener("click", () => { stopTimer(); opts.back(); });978 document.addEventListener("keydown", keyHandler);979 if (opts.timer && !timerId) timerId = setInterval(() => { remaining--; const e2 = $("[data-timer]", root); if (e2) e2.textContent = fmtT(); if (remaining <= 0) { stopTimer(); finish(true); } }, 1000);980 };981 const keyHandler = (e) => {982 if (!root.isConnected) { document.removeEventListener("keydown", keyHandler); return; }983 if (/input|textarea|select/i.test(document.activeElement?.tagName || "")) return;984 const idx = letters.indexOf(e.key.toUpperCase());985 const btns = $$(".q-choice", root);986 if (idx >= 0 && btns[idx] && !btns[idx].disabled) btns[idx].click();987 if (e.key === "Enter") { const n = $("[data-next]", root); if (n && !n.disabled) n.click(); }988 };989 const finish = (timeout) => {990 stopTimer();991 document.removeEventListener("keydown", keyHandler);992 const pct = Math.round((score / qs.length) * 100);993 const pp = progress();994 const prev = pp.quiz[opts.key] || {};995 pp.quiz[opts.key] = { best: Math.max(prev.best || 0, score), last: score, total: qs.length, at: Date.now(), attempts: (prev.attempts || 0) + 1 };996 if (/^\d+$/.test(opts.key) && pct >= 70) pp.chapters[opts.key] = { ...(pp.chapters[opts.key] || {}), done: true };997 saveProgress(pp);998 if (pct >= 90) confetti();999 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.";1000 root.innerHTML = `<div class="quiz"><div class="quiz-result"><div class="q-num">${timeout ? "Temps écoulé · " : ""}Résultat</div><div class="big">${score}/${qs.length}</div><div class="verdict" style="color:${pct >= 70 ? "var(--vert)" : "var(--rouge)"}">${verdict}</div><p>${pct} % de bonnes réponses${prev.best !== undefined ? ` · meilleur score précédent : ${prev.best}/${prev.total}` : ""}</p>1001 <div class="q-actions" style="justify-content:center"><button class="btn" data-retry>Recommencer</button>${opts.back ? `<button class="btn btn-ghost" data-back>Retour au menu</button>` : ""}</div>1002 <div class="quiz-review">${qs.map((q) => `<div class="qr ${q.answer === q.a ? "ok" : "ko"}"><span>${q.answer === q.a ? "✓" : "✗"}</span><div><div>${q.q}</div>${q.answer !== q.a ? `<small>Réponse : ${q.c[q.a]}</small>` : ""}${q.url ? ` <a href="${q.url}" style="font-size:.8rem">→ notes</a>` : ""}</div></div>`).join("")}</div></div></div>`;1003 $("[data-retry]", root).addEventListener("click", () => { remaining = opts.timer || 0; this.run(root, questions, opts); });1004 $("[data-back]", root)?.addEventListener("click", () => opts.back());1005 renderProgressWidgets();1006 };1007 render();1008 },1009 };10101011 // ---------------------------------------------------------------------------1012 // Fiches1013 // ---------------------------------------------------------------------------1014 async function initFlashcards() {1015 const root = $("#flash-app");1016 if (!root) return;1017 const cards = await fetchJSON("/data/cards.json");1018 const p = progress();1019 const boxes = p.cards || {};1020 const chSel = $("#fc-ch"), typeSel = $("#fc-type"), modeSel = $("#fc-mode");1021 const chapters = [...new Set(cards.map((c) => c.ch))].sort((a, b) => (a === "G" ? 1 : b === "G" ? -1 : Number(a) - Number(b)));1022 chSel.innerHTML = `<option value="">Toutes les séances</option>` + chapters.map((c) => `<option value="${c}">${c === "G" ? "Glossaire" : "Séance " + Number(c)}</option>`).join("");1023 const q0 = new URLSearchParams(location.search).get("ch");1024 if (q0 && chapters.includes(q0)) chSel.value = q0;1025 let deck = [], idx = 0;1026 const TYPE_LABEL = { def: "Définition", gloss: "Glossaire", form: "Formule" };1027 const renderStats = () => {1028 const all = cards.filter((c) => (!chSel.value || c.ch === chSel.value) && (!typeSel.value || c.type === typeSel.value));1029 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;1030 $("#fc-stats").innerHTML = `<span class="chip">${all.length} fiches</span><span class="chip chip-vert">${acq} acquises</span><span class="chip chip-or">${enc} en cours</span><span class="chip chip-rouge">${rev} à revoir</span><span class="chip">${all.length - acq - enc - rev} jamais vues</span>`;1031 };1032 const renderCard = () => {1033 const stage = $("#fc-stage");1034 if (!deck.length) { stage.innerHTML = `<div class="fc-empty">Aucune fiche dans cette sélection. Changez les filtres ou le mode.</div>`; $("#fc-nav").textContent = ""; return; }1035 const c = deck[idx];1036 stage.innerHTML = `<div class="fc-card" tabindex="0" aria-label="Fiche : cliquer pour retourner"><div class="fc-face fc-front"><div class="fc-kind">${TYPE_LABEL[c.type]}<span class="fc-ch">${c.ch === "G" ? "Glossaire" : "Séance " + Number(c.ch)}</span></div><div class="fc-q">${c.front}</div><div class="fc-hint">Cliquer ou appuyer sur Espace pour retourner</div></div><div class="fc-face fc-back"><div class="fc-kind">${TYPE_LABEL[c.type]}<span class="fc-ch">${c.num || ""}</span></div><div class="fc-a">${c.back}</div><div class="fc-hint"><a href="${c.url}">Voir dans les notes →</a></div></div></div>`;1037 const card = $(".fc-card", stage);1038 card.addEventListener("click", (e) => { if (!e.target.closest("a")) card.classList.toggle("flipped"); });1039 $("#fc-nav").innerHTML = `<span>Fiche ${idx + 1} / ${deck.length}</span><span>Niveau : ${"●".repeat(boxes[c.id] || 0)}${"○".repeat(3 - (boxes[c.id] || 0))}</span>`;1040 };1041 const filterDeck = () => {1042 const mode = modeSel.value;1043 deck = cards.filter((c) => (!chSel.value || c.ch === chSel.value) && (!typeSel.value || c.type === typeSel.value));1044 if (mode === "review") deck = deck.filter((c) => (boxes[c.id] || 0) < 3);1045 if (mode === "hard") deck = deck.filter((c) => boxes[c.id] === 0);1046 deck = shuffle(deck); idx = 0; renderStats(); renderCard();1047 };1048 const grade = (ok) => {1049 if (!deck.length) return;1050 const c = deck[idx];1051 boxes[c.id] = ok ? Math.min(3, (boxes[c.id] || 0) + 1) : 0;1052 const pp = progress(); pp.cards = boxes; saveProgress(pp);1053 idx = (idx + 1) % deck.length; renderStats(); renderCard();1054 };1055 $("#fc-ok").addEventListener("click", () => grade(true));1056 $("#fc-ko").addEventListener("click", () => grade(false));1057 $("#fc-flip").addEventListener("click", () => $(".fc-card")?.classList.toggle("flipped"));1058 $("#fc-skip").addEventListener("click", () => { if (deck.length) { idx = (idx + 1) % deck.length; renderCard(); } });1059 $("#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(); } });1060 [chSel, typeSel, modeSel].forEach((s) => s.addEventListener("change", filterDeck));1061 document.addEventListener("keydown", (e) => {1062 if (/input|textarea|select/i.test(document.activeElement?.tagName || "")) return;1063 if (e.key === " ") { e.preventDefault(); $(".fc-card")?.classList.toggle("flipped"); }1064 if (e.key === "ArrowRight" || e.key.toLowerCase() === "j") grade(true);1065 if (e.key === "ArrowLeft" || e.key.toLowerCase() === "r") grade(false);1066 });1067 filterDeck();1068 }10691070 // ---------------------------------------------------------------------------1071 // Calculateurs1072 // ---------------------------------------------------------------------------1073 function initTools() {1074 const root = $("#tools-app");1075 if (!root || !window.UQO_TOOLS) return;1076 const tools = window.UQO_TOOLS;1077 const nav = $("#tools-nav");1078 if (nav) nav.innerHTML = tools.map((t) => `<a class="chip chip-bleu" href="#${t.id}">${esc(t.title)}</a>`).join("");1079 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) };1080 root.innerHTML = tools.map((t) => `<section class="tool reveal" id="${t.id}"><div class="tool-head"><h2>${esc(t.title)}</h2>${t.ref ? `<a class="chip" href="${t.ref.url}">${esc(t.ref.label)}</a>` : ""}</div><div class="tool-body"><form class="tool-inputs" data-tool="${t.id}">${t.inputs.map((inp) => inp.group ? `<div class="group-title">${esc(inp.group)}</div>` : inp.type === "select" ? `<label>${esc(inp.label)}<select name="${inp.key}">${inp.options.map((o) => `<option value="${o.value}"${o.value === inp.default ? " selected" : ""}>${esc(o.label)}</option>`).join("")}</select></label>` : `<label>${inp.label}<span class="unit"><input type="number" name="${inp.key}" value="${inp.default}" step="${inp.step || "any"}" ${inp.min !== undefined ? `min="${inp.min}"` : ""} ${inp.max !== undefined ? `max="${inp.max}"` : ""} inputmode="decimal"><span>${esc(inp.unit || "")}</span></span></label>`).join("")}</form><div class="tool-out">${t.desc ? `<p class="desc">${t.desc}</p>` : ""}${t.formula ? `<div class="formula">${t.formula}</div>` : ""}<div class="tool-results"></div>${t.note ? `<p class="note">${t.note}</p>` : ""}</div></div></section>`).join("");1081 tools.forEach((t) => {1082 const form = $(`form[data-tool="${t.id}"]`, root), out = $(`#${t.id} .tool-results`, root);1083 const compute = () => {1084 const v = {};1085 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(",", ".")); } });1086 let res;1087 try { res = t.compute(v); } catch (e) { res = [{ label: "Erreur", value: String(e.message), fmt: "text", cls: "warn" }]; }1088 out.innerHTML = res.map((r) => {1089 if (r.table) return `<table class="tool-table"><thead><tr>${r.table.head.map((h) => `<th>${esc(h)}</th>`).join("")}</tr></thead><tbody>${r.table.rows.map((row) => `<tr>${row.map((c, i) => `<td>${i === 0 ? esc(String(c)) : typeof c === "number" ? (F[r.table.fmt?.[i] || "money"] || fmtMoney)(c) : esc(String(c))}</td>`).join("")}</tr>`).join("")}</tbody></table>`;1090 const f = F[r.fmt || "money"] || fmtMoney;1091 return `<div class="res ${r.cls || ""}"><span>${r.label}</span><b>${f(r.value)}</b></div>`;1092 }).join("");1093 };1094 form.addEventListener("input", compute);1095 form.addEventListener("submit", (e) => e.preventDefault());1096 compute();1097 });1098 $$(".reveal", root).forEach((el) => el.classList.add("in"));1099 }11001101 // ---------------------------------------------------------------------------1102 // Entraînement : problèmes générés1103 // ---------------------------------------------------------------------------1104 function initTraining() {1105 const root = $("#training-app");1106 if (!root || !window.UQO_TRAINING) return;1107 const gens = window.UQO_TRAINING;1108 let current = null, gen = null, mode = "mix", streak = 0;1109 const stats = () => progress().training || {};1110 const menu = $("#training-menu");1111 const renderMenu = () => {1112 const s = stats();1113 menu.innerHTML = `<button class="tchip ${mode === "mix" ? "on" : ""}" data-g="mix">Mélange de tout</button>` + gens.map((g) => { const st = s[g.id] || { ok: 0, n: 0 }; return `<button class="tchip ${mode === g.id ? "on" : ""}" data-g="${g.id}">${esc(g.title)}${st.n ? ` <small>${st.ok}/${st.n}</small>` : ""}</button>`; }).join("");1114 $$("[data-g]", menu).forEach((b) => b.addEventListener("click", () => { mode = b.dataset.g; renderMenu(); next(); }));1115 };1116 const next = () => {1117 gen = mode === "mix" ? R.pick(gens) : gens.find((g) => g.id === mode);1118 current = gen.gen(R);1119 root.innerHTML = `<div class="tcard"><div class="q-num">Séance ${Number(gen.ch)} · ${esc(gen.title)} · <span class="streak">Série : ${streak}</span></div><div class="tq">${current.enonce}</div>1120 <form class="tform"><input type="text" inputmode="decimal" autocomplete="off" placeholder="Votre réponse" aria-label="Réponse"><span class="tunit">${current.unit}</span><button class="btn" type="submit">Vérifier</button></form>1121 <div class="q-feedback"></div>1122 <div class="q-actions"><button class="btn btn-sm btn-ghost" type="button" data-sol>Voir la solution</button><button class="btn btn-sm btn-ghost" type="button" data-skip>Nouveau problème</button><a class="q-link" href="${gen.url}">Relire la section →</a></div></div>`;1123 const form = $("form", root), input = $("input", root), fb = $(".q-feedback", root);1124 input.focus();1125 let answered = false;1126 form.addEventListener("submit", (e) => {1127 e.preventDefault();1128 if (answered) { next(); return; }1129 const v = parseNumber(input.value);1130 if (!isFinite(v)) { fb.className = "q-feedback show ko"; fb.innerHTML = "<b>Entrez un nombre.</b> Les espaces, le symbole $ ou % et la virgule décimale sont acceptés."; return; }1131 const ok = Math.abs(v - current.answer) <= Math.max(Math.abs(current.answer) * current.tol, current.unit === "%" ? 0.02 : 1);1132 answered = true;1133 const p = progress();1134 p.training[gen.id] = { ok: (p.training[gen.id]?.ok || 0) + (ok ? 1 : 0), n: (p.training[gen.id]?.n || 0) + 1 };1135 saveProgress(p);1136 streak = ok ? streak + 1 : 0;1137 fb.className = "q-feedback show " + (ok ? "ok" : "ko");1138 fb.innerHTML = `<b>${ok ? "Exact." : "Inexact."}</b> Réponse attendue : <b>${current.unit === "%" ? fmtPct(current.answer) : fmtMoney2(current.answer)}</b>${ok ? "" : ` (vous avez répondu ${current.unit === "%" ? fmtPct(v) : fmtMoney2(v)}).`}<div class="tsteps">${current.steps}</div>`;1139 $("button[type=submit]", form).textContent = "Problème suivant →";1140 if (ok && streak > 0 && streak % 5 === 0) { toast(`Série de ${streak} bonnes réponses !`, "ok"); confetti(); }1141 renderMenu();1142 $(".streak", root).textContent = `Série : ${streak}`;1143 });1144 $("[data-sol]", root).addEventListener("click", () => { answered = true; streak = 0; fb.className = "q-feedback show"; fb.innerHTML = `<b>Solution.</b> Réponse : <b>${current.unit === "%" ? fmtPct(current.answer) : fmtMoney2(current.answer)}</b><div class="tsteps">${current.steps}</div>`; $("button[type=submit]", form).textContent = "Problème suivant →"; });1145 $("[data-skip]", root).addEventListener("click", next);1146 };1147 renderMenu();1148 next();1149 }11501151 // ---------------------------------------------------------------------------1152 // Jeux de révision1153 // ---------------------------------------------------------------------------1154 async function initGames() {1155 const root = $("#games-app");1156 if (!root) return;1157 const cards = await fetchJSON("/data/cards.json");1158 const seqs = window.UQO_SEQUENCES || [];1159 const tabs = $$(".game-tabs button");1160 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); };1161 const plain = (h) => { const d = document.createElement("div"); d.innerHTML = h; return d.textContent.replace(/\s+/g, " ").trim(); };1162 const pool = (type) => cards.filter((c) => (type ? c.type === type : c.type !== "form") && plain(c.back).length > 30);11631164 const assoc = () => {1165 const picks = shuffle(pool()).slice(0, 6);1166 const rights = shuffle(picks);1167 let sel = null, matched = 0, errors = 0;1168 const t0 = Date.now();1169 root.innerHTML = `<div class="game"><div class="game-head"><h3>Associez chaque terme à sa définition</h3><span class="game-stat">6 paires · <span id="g-err">0</span> erreur(s)</span></div><div class="assoc"><div class="assoc-col">${picks.map((c, i) => `<button class="assoc-item" data-l="${i}">${c.front}</button>`).join("")}</div><div class="assoc-col">${rights.map((c) => `<button class="assoc-item def" data-r="${c.id}">${plain(c.back).slice(0, 160)}${plain(c.back).length > 160 ? "…" : ""}</button>`).join("")}</div></div></div>`;1170 const onPick = (btn) => {1171 if (btn.classList.contains("ok")) return;1172 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; }1173 if (sel === null) return;1174 const card = picks[sel];1175 if (btn.dataset.r === card.id) {1176 btn.classList.add("ok"); $(`.assoc-item[data-l="${sel}"]`, root).classList.add("ok");1177 matched++; sel = null;1178 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", `<div class="quiz-result"><div class="big">${score}/6</div><div class="verdict">Terminé en ${secs} s avec ${errors} erreur(s).</div><div class="q-actions" style="justify-content:center"><button class="btn" data-again>Rejouer</button></div></div>`); $("[data-again]", root).addEventListener("click", assoc); if (errors === 0) confetti(); }1179 } else { errors++; $("#g-err").textContent = errors; btn.classList.add("ko"); setTimeout(() => btn.classList.remove("ko"), 500); }1180 };1181 $$(".assoc-item", root).forEach((b) => b.addEventListener("click", () => onPick(b)));1182 };11831184 const vraiFaux = () => {1185 const src = shuffle(pool()).slice(0, 20);1186 const rounds = [];1187 for (let i = 0; i < 10; i++) {1188 const c = src[i];1189 const wrong = Math.random() < .5;1190 const other = wrong ? src[10 + i] : null;1191 rounds.push({ term: c.front, def: wrong ? other.back : c.back, truth: !wrong, realTerm: other ? other.front : null, url: c.url });1192 }1193 let i = 0, score = 0;1194 const render = () => {1195 const r = rounds[i];1196 root.innerHTML = `<div class="game"><div class="game-head"><h3>Vrai ou faux ?</h3><span class="game-stat">Question ${i + 1}/10 · score ${score}</span></div><div class="q-card"><div class="q-num">Cette définition correspond-elle au terme ?</div><div class="vf-term">${r.term}</div><div class="vf-def">${r.def}</div><div class="q-actions" style="justify-content:center"><button class="btn btn-ok" data-v="1">Vrai</button><button class="btn btn-ko" data-v="0">Faux</button></div><div class="q-feedback"></div><div class="q-actions"><button class="btn btn-sm" data-next hidden>Suivant →</button></div></div></div>`;1197 $$("[data-v]", root).forEach((b) => b.addEventListener("click", () => {1198 const ans = b.dataset.v === "1";1199 const ok = ans === r.truth;1200 if (ok) score++;1201 $$("[data-v]", root).forEach((x) => (x.disabled = true));1202 const fb = $(".q-feedback", root);1203 fb.className = "q-feedback show " + (ok ? "ok" : "ko");1204 fb.innerHTML = `<b>${ok ? "Exact." : "Inexact."}</b> ${r.truth ? "La définition correspond bien au terme." : `Cette définition est celle de <b>${r.realTerm}</b>.`} <a href="${r.url}">Voir dans les notes →</a>`;1205 const n = $("[data-next]", root); n.hidden = false; n.focus();1206 n.addEventListener("click", () => { i++; if (i < 10) render(); else { saveGame("vf", { score, total: 10 }); root.innerHTML = `<div class="quiz-result"><div class="big">${score}/10</div><div class="verdict">${score >= 8 ? "Très bien !" : score >= 6 ? "Bien." : "À retravailler."}</div><div class="q-actions" style="justify-content:center"><button class="btn" data-again>Rejouer</button></div></div>`; $("[data-again]", root).addEventListener("click", vraiFaux); if (score === 10) confetti(); } });1207 }));1208 };1209 render();1210 };12111212 const ordre = () => {1213 if (!seqs.length) { root.innerHTML = "<p>Aucune séquence disponible.</p>"; return; }1214 const seq = R.pick(seqs);1215 const items = shuffle(seq[1].map((s, i) => ({ s, i })));1216 let chosen = [];1217 const render = () => {1218 root.innerHTML = `<div class="game"><div class="game-head"><h3>Remettez dans l’ordre</h3><span class="game-stat">${esc(seq[0])}</span></div><div class="ordre"><div class="ordre-pool">${items.filter((it) => !chosen.includes(it)).map((it) => `<button class="ordre-item" data-i="${it.i}">${it.s}</button>`).join("") || '<span class="muted">Tous les éléments sont placés.</span>'}</div><ol class="ordre-list">${chosen.map((it, k) => `<li><span>${it.s}</span><button class="hl-del" data-undo="${k}" aria-label="Retirer">✕</button></li>`).join("")}</ol></div><div class="q-actions"><button class="btn" data-check ${chosen.length === items.length ? "" : "disabled"}>Vérifier</button><button class="btn btn-ghost btn-sm" data-other>Autre séquence</button><a class="q-link" href="${seq[2]}">Relire la section →</a></div><div class="q-feedback"></div></div>`;1219 $$(".ordre-item", root).forEach((b) => b.addEventListener("click", () => { chosen.push(items.find((it) => it.i === Number(b.dataset.i))); render(); }));1220 $$("[data-undo]", root).forEach((b) => b.addEventListener("click", () => { chosen.splice(Number(b.dataset.undo), 1); render(); }));1221 $("[data-other]", root).addEventListener("click", ordre);1222 $("[data-check]", root).addEventListener("click", () => {1223 const correct = chosen.filter((it, k) => it.i === k).length;1224 const fb = $(".q-feedback", root);1225 fb.className = "q-feedback show " + (correct === items.length ? "ok" : "ko");1226 fb.innerHTML = `<b>${correct}/${items.length} en bonne position.</b>${correct === items.length ? " Séquence parfaite." : ` Ordre correct : <ol>${seq[1].map((s) => `<li>${s}</li>`).join("")}</ol>`}`;1227 $$(".ordre-list li", root).forEach((li, k) => li.classList.add(chosen[k].i === k ? "ok" : "ko"));1228 saveGame("ordre", { score: correct, total: items.length });1229 if (correct === items.length) confetti();1230 });1231 };1232 render();1233 };12341235 const modes = { assoc, vf: vraiFaux, ordre };1236 tabs.forEach((t) => t.addEventListener("click", () => { tabs.forEach((x) => x.setAttribute("aria-selected", String(x === t))); modes[t.dataset.game](); }));1237 (modes[new URLSearchParams(location.search).get("jeu")] || assoc)();1238 }12391240 // ---------------------------------------------------------------------------1241 // Progression (tableau de bord)1242 // ---------------------------------------------------------------------------1243 function initProgressPage() {1244 const root = $("#progress-app");1245 if (!root || !window.UQO_META) return;1246 const meta = window.UQO_META;1247 const render = () => {1248 const p = progress();1249 const done = meta.chapters.filter((c) => p.chapters[c.n]?.done).length;1250 const readAvg = Math.round(meta.chapters.reduce((a, c) => a + (p.chapters[c.n]?.pct || 0), 0) / meta.chapters.length);1251 const quizzes = Object.entries(p.quiz).filter(([k]) => /^\d+$/.test(k));1252 const quizAvg = quizzes.length ? Math.round(quizzes.reduce((a, [, q]) => a + q.best / q.total, 0) / quizzes.length * 100) : null;1253 const objTotal = meta.chapters.reduce((a, c) => a + c.objectives, 0);1254 const objDone = Object.values(p.obj).filter(Boolean).length;1255 const cardsAcq = Object.values(p.cards).filter((b) => b >= 3).length;1256 const tr = Object.values(p.training);1257 const trN = tr.reduce((a, t) => a + t.n, 0), trOk = tr.reduce((a, t) => a + t.ok, 0);1258 root.innerHTML = `1259 <div class="grid-4 prog-tiles">1260 <div class="card stat-card"><b>${done}/14</b><span>séances terminées</span><div class="bar"><i style="width:${done / 14 * 100}%"></i></div></div>1261 <div class="card stat-card"><b>${readAvg} %</b><span>lecture moyenne</span><div class="bar"><i style="width:${readAvg}%"></i></div></div>1262 <div class="card stat-card"><b>${quizAvg === null ? "—" : quizAvg + " %"}</b><span>quiz : moyenne des meilleurs scores (${quizzes.length} séance${quizzes.length > 1 ? "s" : ""})</span><div class="bar"><i style="width:${quizAvg || 0}%"></i></div></div>1263 <div class="card stat-card"><b>${objDone}/${objTotal}</b><span>objectifs cochés</span><div class="bar"><i style="width:${objTotal ? objDone / objTotal * 100 : 0}%"></i></div></div>1264 <div class="card stat-card"><b>${cardsAcq}/${meta.cards}</b><span>fiches acquises</span><div class="bar"><i style="width:${meta.cards ? cardsAcq / meta.cards * 100 : 0}%"></i></div></div>1265 <div class="card stat-card"><b>${trN ? Math.round(trOk / trN * 100) + " %" : "—"}</b><span>entraînement : ${trOk} justes sur ${trN}</span><div class="bar"><i style="width:${trN ? trOk / trN * 100 : 0}%"></i></div></div>1266 <div class="card stat-card"><b>${p.hl.length}</b><span>passages surlignés · ${Object.values(p.notes).filter((n) => n && n.trim()).length} séance(s) avec notes</span></div>1267 <div class="card stat-card"><b>${Object.values(p.games).reduce((a, g) => a + g.played, 0)}</b><span>parties jouées (association, vrai/faux, ordre)</span></div>1268 </div>1269 <h2 class="h-section" style="margin-top:2em"><span class="h-text">Séance par séance</span></h2>1270 <div class="table-wrap"><table class="tbl prog-table"><thead><tr><th>Séance</th><th>Lecture</th><th>Quiz (meilleur)</th><th>Objectifs</th><th>Sections lues</th><th></th></tr></thead><tbody>1271 ${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 `<tr class="${ch.done ? "done" : ""}"><td><a href="/seance/${c.n}/"><b>${Number(c.n)}</b> ${esc(c.title)}</a></td><td><div class="mini-bar"><i style="width:${ch.pct || 0}%"></i></div><small>${ch.pct || 0} %</small></td><td>${q ? `<div class="mini-bar"><i style="width:${q.best / q.total * 100}%;background:${q.best / q.total >= .7 ? "var(--vert)" : "var(--or)"}"></i></div><small>${q.best}/${q.total} · ${q.attempts || 1} essai(s)</small>` : '<small class="muted">non tenté</small>'}</td><td><small>${objs}/${c.objectives}</small></td><td><small>${secs}/${c.sections}</small></td><td>${ch.done ? '<span class="chip chip-vert">terminée</span>' : ch.pct ? '<span class="chip chip-or">en cours</span>' : ""}</td></tr>`; }).join("")}1272 </tbody></table></div>1273 <div class="q-actions" style="margin-top:20px"><button class="btn btn-ghost" data-export>Exporter ma progression (JSON)</button><label class="btn btn-ghost" style="cursor:pointer">Importer <input type="file" accept="application/json" hidden data-import></label><button class="btn btn-ghost" data-notes-export>Exporter toutes mes notes (Markdown)</button><button class="btn" style="background:var(--rouge);margin-left:auto" data-reset>Réinitialiser</button></div>`;1274 $("[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(); });1275 $("[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"); } }); });1276 $("[data-notes-export]", root).addEventListener("click", () => {1277 const pp = progress();1278 let md = `# ${meta.course} — mes notes\n\n`;1279 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`; });1280 const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([md], { type: "text/markdown" })); a.download = `${COURSE}-notes.md`; a.click();1281 });1282 $("[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."); } });1283 };1284 render();1285 }12861287 // ---------------------------------------------------------------------------1288 // Carte du cours1289 // ---------------------------------------------------------------------------1290 function initMap() {1291 const map = $(".course-map");1292 if (!map || !window.UQO_MAP) return;1293 const panel = $("#map-panel");1294 const p = progress();1295 $$("[data-node]", map).forEach((g) => {1296 const n = g.dataset.node;1297 const ch = p.chapters[n];1298 if (ch?.done) g.classList.add("done"); else if (ch?.pct) g.classList.add("started");1299 const show = () => {1300 const d = window.UQO_MAP.chapters[n];1301 if (!panel || !d) return;1302 $$("[data-node]", map).forEach((x) => x.classList.remove("active"));1303 g.classList.add("active");1304 const q = p.quiz[n];1305 panel.innerHTML = `<div class="eyebrow">Séance ${Number(n)} · Partie ${d.part}</div><h3>${d.title}</h3><div class="ch-meta" style="margin:8px 0 12px">${d.counts.map((c) => `<span class="chip">${c}</span>`).join("")}${ch?.pct ? `<span class="chip chip-vert">${ch.pct} % lu</span>` : ""}${q ? `<span class="chip chip-or">quiz ${q.best}/${q.total}</span>` : ""}</div><h4>Objectifs d’apprentissage</h4><ul>${d.objectives.map((o) => `<li>${o}</li>`).join("")}</ul><div class="q-actions"><a class="btn btn-sm" href="/seance/${n}/">Ouvrir la séance</a><a class="btn btn-sm btn-ghost" href="/quiz/">Quiz</a><a class="btn btn-sm btn-ghost" href="/fiches/?ch=${n}">Fiches</a></div>`;1306 };1307 g.addEventListener("mouseenter", show);1308 g.addEventListener("focus", show);1309 g.addEventListener("click", (e) => { if (e.target.closest("a")) return; show(); });1310 });1311 const first = $("[data-node]", map);1312 if (first && panel) first.dispatchEvent(new Event("mouseenter"));1313 }13141315 // ---------------------------------------------------------------------------1316 // Glossaire, diapositives, sommaire latéral, ancres1317 // ---------------------------------------------------------------------------1318 function initGlossaryPage() {1319 const input = $("#gl-search");1320 if (!input) return;1321 const entries = $$(".gl-entry"), letters = $$(".gl-letter"), count = $("#gl-count");1322 const run = () => {1323 const q = norm(input.value.trim());1324 let n = 0;1325 const seen = new Set();1326 entries.forEach((e) => { const hit = !q || norm(e.textContent).includes(q); e.classList.toggle("hidden", !hit); if (hit) { n++; seen.add(e.dataset.letter); } });1327 letters.forEach((l) => l.classList.toggle("hidden", !seen.has(l.dataset.letter)));1328 $$(".gl-letters a").forEach((a) => a.classList.toggle("off", !seen.has(a.textContent.trim())));1329 if (count) count.textContent = q ? `${n} terme${n > 1 ? "s" : ""} correspondant${n > 1 ? "s" : ""}` : `${entries.length} termes`;1330 };1331 input.addEventListener("input", debounce(run, 80));1332 const q0 = new URLSearchParams(location.search).get("q");1333 if (q0) input.value = q0;1334 run();1335 }1336 function initSlides() {1337 const tabs = $$(".slide-tabs button"), frame = $(".slide-viewer iframe"), title = $("#slide-title"), dl = $("#slide-download");1338 if (!tabs.length || !frame) return;1339 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); };1340 tabs.forEach((b) => b.addEventListener("click", () => select(b)));1341 const h = location.hash.replace("#", "");1342 select(tabs.find((t) => t.dataset.n === h) || tabs[0]);1343 }1344 function initSideSpy() {1345 const links = $$(".two-col .side li a[href^='#']");1346 if (!links.length) return;1347 const heads = links.map((a) => document.getElementById(a.getAttribute("href").slice(1))).filter(Boolean);1348 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)); };1349 addEventListener("scroll", debounce(spy, 60), { passive: true });1350 spy();1351 }1352 function initHashDetails() {1353 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); };1354 addEventListener("hashchange", openTarget);1355 openTarget();1356 }1357 function renderProgressWidgets() {1358 const p = progress();1359 $$("[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}` : ""}`; });1360 $$("[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; } });1361 const resume = $("#resume-card");1362 if (resume) {1363 const done = Object.values(p.chapters).filter((c) => c.done).length;1364 const total = Number(resume.dataset.total || 14);1365 const pct = Math.round((done / total) * 100);1366 $(".bar i", resume).style.width = pct + "%";1367 $(".resume-pct", resume).textContent = `${done}/${total} séances terminées · ${pct} %`;1368 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"; }1369 const quizzes = Object.entries(p.quiz).filter(([k]) => /^\d+$/.test(k));1370 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)} %`; }1371 }1372 $$(".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"); });1373 }137413751376 // ---------------------------------------------------------------------------1377 // Explorateurs de données du rapport (HPI ACI, FRED)1378 // ---------------------------------------------------------------------------1379 const PALETTE = ["#0f6180", "#cca424", "#b22a20", "#107c4e", "#6b4fbb", "#2a86b0", "#e07b39", "#0f8b8d", "#8a6d3b", "#555"];1380 function seriesChart(host, series, opts) {1381 // series: [{label, color, points:[[x(index), y]]}], xLabels: [], hover tooltip1382 const w = 760, h = 340, m = { l: 62, r: 16, t: 14, b: 42 };1383 const c = chart(w, h);1384 const xs = opts.xLabels;1385 const all = series.flatMap((s) => s.points.map((p) => p[1])).filter((v) => v !== null && isFinite(v));1386 let ymin = Math.min(...all), ymax = Math.max(...all);1387 if (opts.zeroLine) { ymin = Math.min(ymin, 0); ymax = Math.max(ymax, 0); }1388 const pad = (ymax - ymin) * .08 || 1;1389 ymin -= pad; ymax += pad;1390 const X = (i) => m.l + (i / Math.max(1, xs.length - 1)) * (w - m.l - m.r);1391 const Y = (y) => h - m.b - (y - ymin) / (ymax - ymin) * (h - m.t - m.b);1392 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)); }1393 const step = Math.max(1, Math.round(xs.length / 8));1394 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));1395 if (opts.zeroLine) c.add("line", { x1: m.l, x2: w - m.r, y1: Y(0), y2: Y(0), class: "axis" });1396 c.add("line", { x1: m.l, x2: m.l, y1: Y(ymin), y2: Y(ymax), class: "axis" });1397 series.forEach((s) => {1398 let d = "", pen = false;1399 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; });1400 c.add("path", { d, fill: "none", stroke: s.color, "stroke-width": 2.2, "stroke-linejoin": "round" });1401 });1402 const cursor = c.add("line", { x1: 0, x2: 0, y1: Y(ymin), y2: Y(ymax), class: "mline", opacity: 0 });1403 const dots = series.map((s) => c.add("circle", { r: 5, fill: s.color, stroke: "#fff", "stroke-width": 2, opacity: 0 }));1404 host.appendChild(c.svg);1405 const tip = document.createElement("div"); tip.className = "hpi-tip"; tip.style.display = "none"; host.appendChild(tip);1406 c.svg.addEventListener("mousemove", (ev) => {1407 const r = c.svg.getBoundingClientRect();1408 const px = (ev.clientX - r.left) / r.width * w;1409 const i = Math.max(0, Math.min(xs.length - 1, Math.round((px - m.l) / (w - m.l - m.r) * (xs.length - 1))));1410 cursor.setAttribute("x1", X(i)); cursor.setAttribute("x2", X(i)); cursor.setAttribute("opacity", 1);1411 const rows = [];1412 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(`<span style="color:${s.color}">■</span> ${s.label} : <b>${opts.yfmt(v)}</b>`); });1413 tip.innerHTML = `<div><b>${xs[i]}</b></div>${rows.join("<br>")}`;1414 tip.style.display = "block";1415 tip.style.left = (X(i) / w * r.width) + "px";1416 tip.style.top = (ev.clientY - r.top - 8) + "px";1417 tip.style.transform = X(i) > w * .7 ? "translate(-100%, -110%)" : X(i) < w * .3 ? "translate(0, -110%)" : "translate(-50%, -110%)";1418 });1419 c.svg.addEventListener("mouseleave", () => { cursor.setAttribute("opacity", 0); dots.forEach((d) => d.setAttribute("opacity", 0)); tip.style.display = "none"; });1420 }1421 async function initHPI() {1422 const host = $("#hpi-explorer");1423 if (!host) return;1424 const data = await fetchJSON(host.dataset.src);1425 const names = Object.keys(data.series);1426 const defaults = ["Québec (province)", "Canada", "RMR de Montréal", "RMR de Québec"];1427 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"]];1428 const years = [...new Set(data.dates.map((d) => d.slice(0, 4)))];1429 host.innerHTML = `<div class="w-body"><form class="w-controls"><label class="wctl"><span>Mesure</span><select name="m">${measures.map((x) => `<option value="${x[0]}">${x[1]}</option>`).join("")}</select></label><label class="wctl"><span>Début <output>${years[0]}</output></span><input type="range" name="y0" min="0" max="${years.length - 1}" value="0"></label><label class="wctl"><span>Fin <output>${years[years.length - 1]}</output></span><input type="range" name="y1" min="0" max="${years.length - 1}" value="${years.length - 1}"></label><div class="wctl"><span>Marchés</span><div class="hpi-legend">${names.map((n, i) => `<label><input type="checkbox" name="s" value="${n}" ${defaults.includes(n) ? "checked" : ""}><i style="background:${PALETTE[i % PALETTE.length]}"></i>${n}</label>`).join("")}</div></div></form><div class="w-view"><div class="hpi-wrap" id="hpi-chart"></div><div id="hpi-kpis"></div><p class="w-note" style="border:0;padding:6px 0 0">${data.source}</p></div></div>`;1430 const form = $("form", host);1431 const render = () => {1432 const m = form.elements.m.value;1433 const y0 = Number(form.elements.y0.value), y1 = Math.max(Number(form.elements.y1.value), y0);1434 form.elements.y0.parentElement.querySelector("output").textContent = years[y0];1435 form.elements.y1.parentElement.querySelector("output").textContent = years[y1];1436 const sel = $$("input[name=s]:checked", form).map((x) => x.value);1437 const i0 = data.dates.findIndex((d) => d.startsWith(years[y0]));1438 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; }1439 const xs = data.dates.slice(i0, i1 + 1);1440 const fmt = measures.find((x) => x[0] === m)[2];1441 const yfmt = fmt === "money" ? (v) => fmtNum(v / 1000, 0) + " k$" : fmt === "pct" ? (v) => fmtPct(v, 1) : (v) => fmtNum(v, 0);1442 const series = sel.map((n) => {1443 const s = data.series[n];1444 let arr;1445 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);1446 else if (m === "REBASE") { const base = s.Composite_HPI[i0]; arr = s.Composite_HPI.map((v) => (v !== null && base ? v / base * 100 : null)); }1447 else arr = s[m] || [];1448 return { label: n, color: PALETTE[names.indexOf(n) % PALETTE.length], points: xs.map((_, k) => [k, arr[i0 + k] ?? null]), raw: arr };1449 });1450 const chartHost = $("#hpi-chart", host); chartHost.innerHTML = "";1451 if (!series.length) { chartHost.innerHTML = '<p class="gl-count">Sélectionnez au moins un marché.</p>'; return; }1452 seriesChart(chartHost, series, { xLabels: xs, yfmt, zeroLine: m === "YOY" });1453 // indicateurs1454 const rows = series.map((s) => {1455 const vals = s.points.map((p) => p[1]).filter((v) => v !== null && isFinite(v));1456 const first = vals[0], last = vals[vals.length - 1];1457 const yrs = vals.length / 12;1458 const cagr = first > 0 && yrs > 0 ? (Math.pow(last / first, 1 / yrs) - 1) * 100 : null;1459 let peak = -Infinity, dd = 0; vals.forEach((v) => { peak = Math.max(peak, v); dd = Math.min(dd, (v / peak - 1) * 100); });1460 const yoy = vals.length > 12 ? (last / vals[vals.length - 13] - 1) * 100 : null;1461 return `<tr><td><span style="color:${s.color}">■</span> ${s.label}</td><td>${yfmt(first)}</td><td>${yfmt(last)}</td><td>${fmt === "pct" ? "—" : fmtPct((last / first - 1) * 100, 1)}</td><td>${cagr === null || fmt === "pct" ? "—" : fmtPct(cagr, 1)}</td><td>${yoy === null || fmt === "pct" ? "—" : fmtPct(yoy, 1)}</td><td>${fmt === "pct" ? "—" : fmtPct(dd, 1)}</td></tr>`;1462 });1463 $("#hpi-kpis", host).innerHTML = `<table class="wtable"><thead><tr><th>Marché</th><th>Début</th><th>Fin</th><th>Variation totale</th><th>Croissance annuelle</th><th>12 derniers mois</th><th>Recul max. depuis un sommet</th></tr></thead><tbody>${rows.join("")}</tbody></table>`;1464 };1465 form.addEventListener("input", render);1466 render();1467 }1468 async function initMacro() {1469 const host = $("#macro-explorer");1470 if (!host) return;1471 const data = await fetchJSON(host.dataset.src);1472 const ids = Object.keys(data);1473 host.innerHTML = `<div class="w-body"><form class="w-controls"><label class="wctl"><span>Série</span><select name="a">${ids.map((id) => `<option value="${id}">${data[id].label}</option>`).join("")}</select></label><label class="wctl"><span>Comparer avec</span><select name="b"><option value="">— aucune —</option>${ids.map((id) => `<option value="${id}">${data[id].label}</option>`).join("")}</select></label><label class="wctl"><span>Depuis</span><select name="y"><option value="2005">2005</option><option value="2010">2010</option><option value="2015">2015</option><option value="2019">2019</option><option value="2022">2022</option></select></label></form><div class="w-view"><div class="hpi-wrap" id="macro-chart"></div><div id="macro-kpis" class="wkpis"></div><p class="w-note" style="border:0;padding:6px 0 0">Source : FRED, Federal Reserve Bank of St. Louis (séries OCDE et Statistique Canada). Le taux de change quotidien est moyenné par mois.</p></div></div>`;1474 const form = $("form", host);1475 if (ids.includes("IRLTLT01CAM156N")) form.elements.b.value = "IRLTLT01CAM156N";1476 const render = () => {1477 const a = data[form.elements.a.value], b = data[form.elements.b.value];1478 const since = form.elements.y.value;1479 const pa = a.points.filter((p) => p[0] >= since);1480 const xs = pa.map((p) => p[0]);1481 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);1482 const series = [{ label: `${a.label} (${a.unit})`, color: PALETTE[0], points: pa.map((p, i) => [i, p[1]]) }];1483 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]) }); }1484 const chartHost = $("#macro-chart", host); chartHost.innerHTML = "";1485 seriesChart(chartHost, series, { xLabels: xs, yfmt: unitFmt(a.unit), zeroLine: a.unit === "%" });1486 const vals = pa.map((p) => p[1]);1487 const f = unitFmt(a.unit);1488 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) }];1489 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" });1490 $("#macro-kpis", host).innerHTML = kp.map((k) => `<div class="wkpi ${k.cls || ""}"><span>${k.label}</span><b>${k.value}</b></div>`).join("");1491 };1492 form.addEventListener("input", render);1493 render();1494 }1495 function initLightbox() {1496 $$(".figimg img").forEach((img) => img.addEventListener("click", () => {1497 const lb = document.createElement("div"); lb.className = "lightbox"; lb.innerHTML = `<img src="${img.src}" alt="">`;1498 lb.addEventListener("click", () => lb.remove()); body.appendChild(lb);1499 }));1500 }15011502 // ---------------------------------------------------------------------------1503 // Séances vidéo : lecteur personnalisé, chapitres, transcription synchronisée, storyboard1504 // ---------------------------------------------------------------------------1505 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")}`; };1506 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); };1507 const VI = {1508 play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.5v15l13-7.5z"/></svg>',1509 pause: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4.5" height="16" rx="1"/><rect x="13.5" y="4" width="4.5" height="16" rx="1"/></svg>',1510 back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/><text x="8.2" y="16" font-size="8" font-family="Fira Sans, sans-serif" font-weight="700" fill="currentColor" stroke="none">10</text></svg>',1511 fwd: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/><text x="8.2" y="16" font-size="8" font-family="Fira Sans, sans-serif" font-weight="700" fill="currentColor" stroke="none">10</text></svg>',1512 vol: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14"/></svg>',1513 mute: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4zM22 9l-6 6M16 9l6 6"/></svg>',1514 cc: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="4" width="20" height="16" rx="3"/><path d="M10.5 10.5a2.5 2.5 0 1 0 0 3M17.5 10.5a2.5 2.5 0 1 0 0 3"/></svg>',1515 pip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><rect x="12" y="11" width="8" height="6" rx="1" fill="currentColor"/></svg>',1516 theater: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/></svg>',1517 theaterOff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>',1518 full: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>',1519 fullOff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8V5h3M21 8V5h-3M3 16v3h3M21 16v3h-3M8 3v5H3M16 3v5h5M8 21v-5H3M16 21v-5h5"/></svg>',1520 skip: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 5v14l9-7zM16 5h3v14h-3z"/></svg>',1521 };1522 function initVideosIndex() {1523 $$("[data-video-progress]").forEach((el) => {1524 const st = store.get("video:" + el.dataset.videoProgress, null);1525 const bar = $(".bar i", el), lab = $("span", el);1526 if (!st || !st.dur) return;1527 const pct = Math.round(Math.min(100, (st.max || st.t) / st.dur * 100));1528 if (pct < 1 && !st.done) return;1529 bar.style.width = pct + "%";1530 if (st.done || pct >= 90) { el.classList.add("done"); lab.textContent = "Séance visionnée"; }1531 else lab.textContent = `Vu à ${pct} % · reprendre à ${hms(st.t)}`;1532 });1533 }1534 async function initVideo() {1535 const main = $(".video-page");1536 if (!main) return;1537 const nn = main.dataset.video, video = $("#vid", main), player = $("#vid-player", main);1538 const key = "video:" + nn;1539 const startT = Number(main.dataset.start || 0);1540 const p = progress(); p.last = { ch: nn, title: main.dataset.title, url: location.pathname }; saveProgress(p);1541 let data = null;1542 try { data = await fetchJSON(main.dataset.src); } catch (e) { $("#tr-list").innerHTML = '<p class="gl-count">Transcription indisponible.</p>'; }1543 const chaps = (data && data.chapters) || [];1544 const blocks = (data && data.blocks) || [];1545 const sb = (data && data.storyboard) || [];1546 const dur = () => (isFinite(video.duration) && video.duration) || (data && data.duration) || 0;1547 const clamp = (t) => Math.max(0, Math.min(t, dur() ? dur() - .3 : t));1548 const seek = (t, play = true) => { try { video.currentTime = clamp(t); } catch (e) { /* not ready */ } if (play) video.play().catch(() => {}); };1549 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; };1550 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; };15511552 // ---- interface du lecteur (remplace les contrôles natifs)1553 video.removeAttribute("controls");1554 const ui = document.createElement("div"); ui.className = "vp-ui";1555 const total = dur() || (chaps.length ? chaps[chaps.length - 1].end : 1);1556 const segs = chaps.length ? chaps : [{ t: 0, end: total, title: "" }];1557 const li = (c) => $(`.vid-chaps .vc[data-t="${c.t}"]`, main);1558 ui.innerHTML = `1559 <div class="vp-click"></div>1560 <button type="button" class="vp-big" aria-label="Lecture">${VI.play}</button>1561 <div class="vp-flash"></div>1562 <div class="vp-top"><span class="vp-chap-badge" id="vp-chap" hidden></span></div>1563 ${startT ? `<button type="button" class="vp-skip" hidden>${VI.skip} Passer l’accueil · début du cours à ${hms(startT)}</button>` : ""}1564 <div class="vp-toast" id="vp-toast"></div>1565 <div class="vp-controls">1566 <div class="vp-seek" role="slider" aria-label="Position dans la vidéo" aria-valuemin="0" aria-valuemax="${Math.round(total)}" aria-valuenow="0" tabindex="0">1567 <div class="vp-track">${segs.map((c) => `<span class="vp-seg${li(c)?.classList.contains("intro") ? " intro" : ""}${li(c)?.classList.contains("question") ? " question" : ""}" style="flex-basis:${Math.max(.3, (c.end - c.t) / total * 100)}%" data-t="${c.t}" data-end="${c.end}"><i class="buf"></i><i class="played"></i></span>`).join("")}</div>1568 <div class="vp-knob"></div>1569 <div class="vp-preview" hidden><img alt=""><div class="vp-pv-cap"><b></b><span></span></div></div>1570 </div>1571 <div class="vp-row">1572 <button type="button" class="vp-btn play" aria-label="Lecture">${VI.play}</button>1573 <button type="button" class="vp-btn back" aria-label="Reculer de 10 secondes">${VI.back}</button>1574 <button type="button" class="vp-btn fwd" aria-label="Avancer de 10 secondes">${VI.fwd}</button>1575 <span class="vp-vol"><button type="button" class="vp-btn mute" aria-label="Son">${VI.vol}</button><input type="range" min="0" max="1" step=".02" value="1" aria-label="Volume"></span>1576 <span class="vp-time"><b class="cur">0:00</b> <span>/ ${hms(total)}</span></span>1577 <span class="vp-title sp" id="vp-title"></span>1578 <span class="vp-menu-wrap"><button type="button" class="vp-btn txt rate" aria-haspopup="true" aria-expanded="false" aria-label="Vitesse">1×</button><div class="vp-menu rate-menu">${[.75, 1, 1.25, 1.5, 1.75, 2].map((r) => `<button type="button" role="menuitemradio" data-rate="${r}" aria-checked="${r === 1}"><span>${String(r).replace(".", ",")}×</span>${r === 1 ? "<small>normale</small>" : ""}</button>`).join("")}</div></span>1579 <button type="button" class="vp-btn cc" aria-label="Sous-titres" aria-pressed="false" title="Sous-titres (c)">${VI.cc}</button>1580 ${document.pictureInPictureEnabled ? `<button type="button" class="vp-btn pip" aria-label="Image dans l’image" title="Image dans l’image">${VI.pip}</button>` : ""}1581 <button type="button" class="vp-btn theater" aria-label="Mode cinéma" aria-pressed="false" title="Mode cinéma (t)">${VI.theater}</button>1582 <button type="button" class="vp-btn full" aria-label="Plein écran" title="Plein écran (f)">${VI.full}</button>1583 </div>1584 </div>`;1585 player.appendChild(ui);1586 const q = (s) => $(s, ui);1587 const playBtns = [q(".vp-big"), q(".vp-btn.play")];1588 const toggle = () => { video.paused ? video.play().catch(() => {}) : video.pause(); };1589 const flash = (svg) => { const f = q(".vp-flash"); f.innerHTML = svg; f.classList.remove("go"); void f.offsetWidth; f.classList.add("go"); };1590 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); };1591 playBtns.forEach((b) => b.addEventListener("click", toggle));1592 let tapWasIdle = false;1593 q(".vp-click").addEventListener("pointerdown", () => { tapWasIdle = player.classList.contains("vp-idle"); });1594 q(".vp-click").addEventListener("click", () => { if (tapWasIdle && matchMedia("(pointer: coarse)").matches) return; toggle(); flash(video.paused ? VI.play : VI.pause); });1595 q(".vp-click").addEventListener("dblclick", () => fullscreen());1596 q(".vp-btn.back").addEventListener("click", () => { seek(video.currentTime - 10, !video.paused); vtoast("−10 s"); });1597 q(".vp-btn.fwd").addEventListener("click", () => { seek(video.currentTime + 10, !video.paused); vtoast("+10 s"); });1598 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"); };1599 video.addEventListener("play", () => { player.dataset.state = "playing"; setState(); });1600 video.addEventListener("pause", () => { player.dataset.state = "paused"; setState(); });1601 // masquage automatique1602 let idleTimer, overControls = false;1603 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); };1604 q(".vp-controls").addEventListener("mouseenter", () => { overControls = true; }); q(".vp-controls").addEventListener("mouseleave", () => { overControls = false; });1605 ["mousemove", "touchstart", "keydown", "click"].forEach((ev) => player.addEventListener(ev, wake, { passive: true }));1606 player.addEventListener("mouseleave", () => { if (!video.paused) player.classList.add("vp-idle"); });1607 video.addEventListener("play", wake);1608 // barre de progression1609 const seekEl = q(".vp-seek"), track = q(".vp-track"), knob = q(".vp-knob"), preview = q(".vp-preview");1610 const segEls = $$(".vp-seg", track);1611 const tFromX = (x) => { const r = track.getBoundingClientRect(); return Math.max(0, Math.min(1, (x - r.left) / r.width)) * (dur() || total); };1612 const showPreview = (x) => {1613 const t = tFromX(x); const r = track.getBoundingClientRect(); const px = Math.max(0, Math.min(1, (x - r.left) / r.width));1614 const f = frameAt(t); const ci = chapAt(t);1615 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";1616 $("b", preview).textContent = hms(t); $("span", preview).textContent = ci >= 0 ? chaps[ci].title : "";1617 const w = preview.offsetWidth || 220, half = w / 2 / r.width; const left = Math.max(half, Math.min(1 - half, px)) * 100;1618 preview.style.left = `${left}%`; knob.style.left = `${px * 100}%`; knob.style.transform = "translate(-50%, -50%) scale(1)";1619 };1620 let dragging = false, wasPlaying = false;1621 seekEl.addEventListener("pointermove", (e) => { if (!dragging) showPreview(e.clientX); else { showPreview(e.clientX); video.currentTime = clamp(tFromX(e.clientX)); } });1622 seekEl.addEventListener("pointerleave", () => { if (!dragging) { preview.hidden = true; knob.style.transform = ""; } });1623 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)); });1624 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(() => {}); };1625 seekEl.addEventListener("pointerup", endDrag); seekEl.addEventListener("pointercancel", endDrag);1626 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); } });1627 // volume1628 const volIn = q(".vp-vol input"), muteBtn = q(".vp-btn.mute");1629 const syncVol = () => { muteBtn.innerHTML = video.muted || video.volume === 0 ? VI.mute : VI.vol; volIn.value = video.muted ? 0 : video.volume; };1630 muteBtn.addEventListener("click", () => { video.muted = !video.muted; store.setGlobal("video-muted", video.muted); syncVol(); });1631 volIn.addEventListener("input", () => { video.volume = Number(volIn.value); video.muted = video.volume === 0; store.setGlobal("video-volume", video.volume); syncVol(); });1632 video.volume = store.getGlobal("video-volume", 1); video.muted = store.getGlobal("video-muted", false); syncVol();1633 // vitesse1634 const rateBtn = q(".vp-btn.rate"), rateMenu = q(".rate-menu");1635 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); };1636 rateBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = rateMenu.classList.toggle("open"); rateBtn.setAttribute("aria-expanded", String(open)); });1637 $$("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()}`); }));1638 document.addEventListener("click", (e) => { if (!e.target.closest(".vp-menu-wrap")) { rateMenu.classList.remove("open"); rateBtn.setAttribute("aria-expanded", "false"); } });1639 setRate(store.getGlobal("video-rate", 1));1640 // sous-titres1641 const ccBtn = q(".vp-btn.cc");1642 const subs = () => Array.from(video.textTracks || []).find((t) => t.kind === "subtitles");1643 const setCC = (on) => { const t = subs(); if (t) t.mode = on ? "showing" : "hidden"; ccBtn.setAttribute("aria-pressed", String(on)); store.setGlobal("video-cc", on); };1644 Array.from(video.textTracks || []).forEach((t) => { t.mode = "hidden"; });1645 ccBtn.addEventListener("click", () => { const on = ccBtn.getAttribute("aria-pressed") !== "true"; setCC(on); vtoast(on ? "Sous-titres activés" : "Sous-titres désactivés"); });1646 setCC(store.getGlobal("video-cc", false));1647 // image dans l'image, cinéma, plein écran1648 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"); } });1649 const theaterBtn = q(".vp-btn.theater");1650 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); };1651 theaterBtn.addEventListener("click", () => setTheater(!main.classList.contains("theater")));1652 if (innerWidth > 1100) setTheater(store.getGlobal("video-theater", false));1653 const fullBtn = q(".vp-btn.full");1654 const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen(); else if (player.requestFullscreen) player.requestFullscreen().catch(() => {}); else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); };1655 fullBtn.addEventListener("click", fullscreen);1656 document.addEventListener("fullscreenchange", () => { fullBtn.innerHTML = document.fullscreenElement ? VI.fullOff : VI.full; });1657 // passer l'accueil1658 const skipBtn = q(".vp-skip");1659 skipBtn?.addEventListener("click", () => { seek(startT); vtoast("Début du cours"); });16601661 // ---- onglets du panneau1662 const tabs = $$(".vid-tabs button", main), panes = $$(".vid-pane", main);1663 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); };1664 tabs.forEach((b) => b.addEventListener("click", () => showPane(b.dataset.pane)));1665 const savedPane = store.getGlobal("video-pane", "chapters"); if (tabs.some((b) => b.dataset.pane === savedPane)) showPane(savedPane);1666 // ---- chapitres (liste)1667 const chapEls = $$(".vid-chaps .vc", main);1668 chapEls.forEach((el) => { $(".vc-btn", el).addEventListener("click", () => { seek(Number(el.dataset.t)); if (innerWidth <= 1100) player.scrollIntoView({ behavior: "smooth", block: "start" }); }); });1669 // ---- transcription1670 const list = $("#tr-list", main), follow = $("#tr-follow", main), search = $("#tr-search", main), count = $("#tr-count", main);1671 let trEls = [];1672 if (list && blocks.length) {1673 const frag = document.createDocumentFragment();1674 let ci = -1;1675 blocks.forEach((b, i) => {1676 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); }1677 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 = `<span class="tr-time">${hms(b[0])}</span><span class="tr-text">${esc(b[2])}</span>`;1678 pEl.addEventListener("click", () => seek(b[0]));1679 frag.appendChild(pEl);1680 });1681 list.innerHTML = ""; list.appendChild(frag);1682 trEls = $$(".tr-seg", list);1683 let userScrolled = false, scrollTimer;1684 const mark = () => { userScrolled = true; clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { userScrolled = false; }, 4000); };1685 list.parentElement.addEventListener("wheel", mark, { passive: true }); list.parentElement.addEventListener("touchmove", mark, { passive: true });1686 main._trFollow = () => follow.checked && !userScrolled;1687 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ç]");1688 const runSearch = debounce(() => {1689 const qv = norm(search.value.trim());1690 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; }1691 const words = qv.split(/\s+/).filter((w) => w.length > 1).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));1692 const re = new RegExp("(" + words.map(acc).join("|") + ")", "gi");1693 let n = 0;1694 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, "<mark>$1</mark>"); } });1695 $$(".tr-chap", list).forEach((h) => { h.hidden = true; });1696 count.textContent = n ? `${n} passage${n > 1 ? "s" : ""} — cliquez pour y aller` : "Aucun passage ne contient ces mots.";1697 }, 120);1698 search?.addEventListener("input", runSearch);1699 }1700 // ---- storyboard1701 const sbHost = $("#vid-sb", main);1702 let sbEls = [];1703 if (sbHost && sb.length) {1704 sbHost.innerHTML = sb.map((f, i) => `<figure data-t="${f.t}" data-i="${i}"><img src="${f.src}" alt="" loading="lazy" decoding="async"><figcaption>${hms(f.t)}</figcaption></figure>`).join("");1705 sbEls = $$("figure", sbHost);1706 sbEls.forEach((f) => f.addEventListener("click", () => seek(Number(f.dataset.t))));1707 } else if (sbHost) sbHost.closest(".vid-sb-wrap").hidden = true;1708 // ---- questions / déroulé1709 $$(".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" }); }));1710 // ---- barre secondaire1711 const audioBtn = $("[data-vid='audio']", main);1712 audioBtn?.addEventListener("click", () => {1713 const on = audioBtn.getAttribute("aria-pressed") !== "true";1714 const t = video.currentTime, playing = !video.paused;1715 video.src = on ? video.dataset.audioSrc : video.dataset.videoSrc;1716 video.classList.toggle("audio-only", on); player.classList.toggle("audio-mode", on);1717 audioBtn.setAttribute("aria-pressed", String(on));1718 video.addEventListener("loadedmetadata", () => { video.currentTime = t; if (playing) video.play().catch(() => {}); }, { once: true });1719 video.load();1720 toast(on ? "Audio seulement : la vidéo n’est plus téléchargée." : "Retour à la vidéo.");1721 });1722 $("[data-vid='link']", main)?.addEventListener("click", async () => {1723 const url = `${location.origin}${location.pathname}?t=${Math.floor(video.currentTime)}`;1724 try { await navigator.clipboard.writeText(url); toast(`Lien copié : ${hms(video.currentTime)}`); } catch (e) { prompt("Copiez ce lien :", url); }1725 });1726 const resume = $("#vid-resume", main);1727 $("[data-vid='resume']", main)?.addEventListener("click", () => { const st = store.get(key, null); resume.hidden = true; if (st) seek(st.t); });1728 $("[data-vid='restart']", main)?.addEventListener("click", () => { resume.hidden = true; seek(startT || 0); });1729 // ---- position de départ : ?t= / #t= / reprise1730 const qs = new URLSearchParams(location.search);1731 const wanted = parseT(qs.get("t") || (location.hash.startsWith("#t=") ? location.hash.slice(3) : ""));1732 const saved = store.get(key, null);1733 const onMeta = () => {1734 $(".vp-time span", ui).textContent = `/ ${hms(dur())}`; seekEl.setAttribute("aria-valuemax", String(Math.round(dur())));1735 if (!isNaN(wanted)) { seek(wanted, false); player.dataset.state = "paused"; vtoast(`Position : ${hms(wanted)}`); }1736 else if (saved && saved.t > 45 && saved.t < dur() - 60 && !saved.done) { $("#vid-resume-t").textContent = hms(saved.t); resume.hidden = false; }1737 update();1738 };1739 if (video.readyState >= 1) onMeta(); else video.addEventListener("loadedmetadata", onMeta, { once: true });1740 video.addEventListener("play", () => { resume.hidden = true; });1741 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}%`; }); });1742 // ---- synchronisation1743 let curChap = -1, curSeg = -1, curSb = -1, lastSave = 0;1744 const chapNow = $("#vid-chapnow", main), badge = q("#vp-chap"), title = q("#vp-title");1745 const update = () => {1746 const t = video.currentTime, d = dur();1747 $(".vp-time .cur", ui).textContent = hms(t); seekEl.setAttribute("aria-valuenow", String(Math.round(t))); seekEl.setAttribute("aria-valuetext", hms(t));1748 if (!dragging) knob.style.left = `${d ? t / d * 100 : 0}%`;1749 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}%`; });1750 if (skipBtn) skipBtn.hidden = !(startT && t < startT - 5);1751 const ci = chapAt(t);1752 if (ci !== curChap) {1753 curChap = ci; const c = chaps[ci];1754 chapEls.forEach((el, i) => { el.classList.toggle("cur", i === ci); el.classList.toggle("seen", i < ci); });1755 const el = chapEls[ci]; if (el && !$(".vid-pane[data-pane='chapters']", main).hidden) el.scrollIntoView({ block: "nearest", behavior: "smooth" });1756 if (c) { badge.hidden = false; badge.innerHTML = `<small>${hms(c.t)}</small>${esc(c.title)}`; title.textContent = c.title; if (chapNow) chapNow.innerHTML = `<b>${hms(c.t)}</b>${esc(c.title)}`; } else { badge.hidden = true; title.textContent = ""; }1757 }1758 if (trEls.length) {1759 let si = -1; for (let i = 0; i < blocks.length; i++) if (blocks[i][0] <= t + .3) si = i; else break;1760 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" }); }1761 }1762 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" }); } }1763 if (!video.paused && Date.now() - lastSave > 4000) { lastSave = Date.now(); save(); }1764 };1765 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; };1766 video.addEventListener("timeupdate", update);1767 video.addEventListener("seeked", update);1768 video.addEventListener("pause", save);1769 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(); });1770 addEventListener("pagehide", save);1771 if (saved && saved.done) { const done = $("[data-video-done]", main); if (done) done.hidden = false; }1772 // ---- clavier1773 addEventListener("keydown", (e) => {1774 if (e.target.matches("input, textarea, select") || e.metaKey || e.ctrlKey || e.altKey) return;1775 if (e.key === " " || e.key === "k") { e.preventDefault(); toggle(); flash(video.paused ? VI.play : VI.pause); }1776 else if (e.key === "ArrowLeft" || e.key === "j") { e.preventDefault(); seek(video.currentTime - 10, !video.paused); vtoast("−10 s"); }1777 else if (e.key === "ArrowRight" || e.key === "l") { e.preventDefault(); seek(video.currentTime + 10, !video.paused); vtoast("+10 s"); }1778 else if (e.key === "c") ccBtn.click();1779 else if (e.key === "f") fullscreen();1780 else if (e.key === "t") theaterBtn.click();1781 else if (e.key === "m") muteBtn.click();1782 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}`); }1783 else if (/^[0-9]$/.test(e.key)) { seek(dur() * Number(e.key) / 10, !video.paused); }1784 wake();1785 });1786 const kbd = document.createElement("p"); kbd.className = "vid-kbd"; kbd.innerHTML = "<kbd>espace</kbd> lecture/pause · <kbd>←</kbd> <kbd>→</kbd> ±10 s · <kbd>c</kbd> sous-titres · <kbd><</kbd> <kbd>></kbd> vitesse · <kbd>t</kbd> mode cinéma · <kbd>f</kbd> plein écran · <kbd>m</kbd> muet · survolez la barre pour voir la diapositive"; $(".vid-bar", main).after(kbd);1787 }17881789 // ---------------------------------------------------------------------------1790 // Démarrage1791 // ---------------------------------------------------------------------------1792 initTheme();1793 initNav();1794 initSearch();1795 initGlossaryTips();1796 initChapter();1797 if ($("#quiz-page")) Quiz.mount($("#quiz-page"), {});1798 initFlashcards();1799 initTools();1800 initTraining();1801 initGames();1802 initProgressPage();1803 initMap();1804 initGlossaryPage();1805 initSlides();1806 initSideSpy();1807 initHashDetails();1808 renderProgressWidgets();1809 Widgets.mountAll();1810 initHPI();1811 initMacro();1812 initLightbox();1813 initVideosIndex();1814 initVideo();1815})();1816