SPB Git forge

spb/uqo-imm1003

Public
7commits 1branches 0releases
189.5 MBsize
maindefault branch
9 h agolast push
JavaScript 68% CSS 32%
149.5 KB · 1,816 lines javascript
Raw Blame History
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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>&lt;</kbd> <kbd>&gt;</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