/* =============================================================================
UQO — Notes de cours interactives — application cliente v2 (sans dépendance)
Modules : stockage, thème, navigation, recherche, infobulles, séance (lecture,
sommaire, objectifs, révision, surlignage, notes, audio), widgets visuels,
quiz, fiches, calculateurs, entraînement, jeux, progression, carte du cours.
============================================================================= */
(() => {
"use strict";
const body = document.body;
const COURSE = body.dataset.course || "cours";
const PREFIX = `uqo:${COURSE}:`;
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// ---------------------------------------------------------------------------
// Stockage local
// ---------------------------------------------------------------------------
const store = {
get(key, def) { try { const v = localStorage.getItem(PREFIX + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } },
set(key, val) { try { localStorage.setItem(PREFIX + key, JSON.stringify(val)); } catch (e) { /* quota */ } },
getGlobal(key, def) { try { const v = localStorage.getItem("uqo:" + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } },
setGlobal(key, val) { try { localStorage.setItem("uqo:" + key, JSON.stringify(val)); } catch (e) { /* quota */ } },
};
const DEFAULT_PROGRESS = { chapters: {}, quiz: {}, obj: {}, cards: {}, last: null, training: {}, games: {}, hl: [], notes: {}, sections: {} };
const progress = () => ({ ...DEFAULT_PROGRESS, ...store.get("progress", {}) });
const saveProgress = (p) => store.set("progress", p);
// ---------------------------------------------------------------------------
// Utilitaires
// ---------------------------------------------------------------------------
const nf = (d) => new Intl.NumberFormat("fr-CA", { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtMoney = (v) => (isFinite(v) ? nf(0).format(Math.round(v)) + " $" : "—");
const fmtMoney2 = (v) => (isFinite(v) ? nf(2).format(v) + " $" : "—");
const fmtPct = (v, d = 2) => (isFinite(v) ? nf(d).format(v) + " %" : "—");
const fmtNum = (v, d = 2) => (isFinite(v) ? new Intl.NumberFormat("fr-CA", { maximumFractionDigits: d }).format(v) : "—");
const norm = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[’']/g, "'");
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const shuffle = (arr, seed) => {
const a = arr.slice();
let s = seed || Math.floor(Math.random() * 1e9);
const rnd = () => { s = (s * 1664525 + 1013904223) % 4294967296; return s / 4294967296; };
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
};
const fetchJSON = (() => { const cache = {}; return (url) => (cache[url] ||= fetch(url).then((r) => { if (!r.ok) throw new Error(url); return r.json(); })); })();
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
const parseNumber = (s) => { const t = String(s).replace(/[\s $%]/g, "").replace(/,/g, "."); const v = parseFloat(t); return isFinite(v) ? v : NaN; };
const R = {
int: (a, b) => a + Math.floor(Math.random() * (b - a + 1)),
pick: (arr) => arr[Math.floor(Math.random() * arr.length)],
step: (a, b, st) => a + st * Math.floor(Math.random() * (Math.floor((b - a) / st) + 1)),
money: fmtMoney, money2: fmtMoney2, pct: fmtPct, num: fmtNum,
};
function toast(msg, kind = "") {
let host = $("#toasts");
if (!host) { host = document.createElement("div"); host.id = "toasts"; host.className = "toasts"; body.appendChild(host); }
const t = document.createElement("div");
t.className = "toast " + kind;
t.innerHTML = msg;
host.appendChild(t);
requestAnimationFrame(() => t.classList.add("in"));
setTimeout(() => { t.classList.remove("in"); setTimeout(() => t.remove(), 300); }, 3600);
}
function confetti() {
const c = document.createElement("canvas");
c.className = "confetti";
body.appendChild(c);
const ctx = c.getContext("2d");
c.width = innerWidth; c.height = innerHeight;
const colors = ["#cca424", "#2976bb", "#107c4e", "#ffffff", "#f1d67a"];
const parts = Array.from({ length: 140 }, () => ({ x: innerWidth / 2 + (Math.random() - .5) * 200, y: innerHeight * .35, vx: (Math.random() - .5) * 14, vy: -Math.random() * 14 - 4, s: 4 + Math.random() * 6, c: colors[Math.floor(Math.random() * colors.length)], r: Math.random() * Math.PI }));
let frame = 0;
const tick = () => {
ctx.clearRect(0, 0, c.width, c.height);
parts.forEach((p) => { p.vy += .35; p.x += p.vx; p.y += p.vy; p.r += .1; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c; ctx.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * .6); ctx.restore(); });
if (frame++ < 130) requestAnimationFrame(tick); else c.remove();
};
tick();
}
// ---------------------------------------------------------------------------
// Thème, taille de police, révélation au défilement
// ---------------------------------------------------------------------------
function initTheme() {
const saved = store.getGlobal("theme", null);
const prefersDark = matchMedia && matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme = saved || (prefersDark ? "dark" : "light");
const fs = store.getGlobal("fs", null);
if (fs) document.documentElement.dataset.fs = fs;
$$("[data-action='theme']").forEach((b) => b.addEventListener("click", () => {
const t = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = t;
store.setGlobal("theme", t);
}));
$$("[data-action='fontsize']").forEach((b) => b.addEventListener("click", () => {
const order = ["s", "", "l", "xl"];
const cur = document.documentElement.dataset.fs || "";
const next = order[(order.indexOf(cur) + 1) % order.length];
if (next) document.documentElement.dataset.fs = next; else delete document.documentElement.dataset.fs;
store.setGlobal("fs", next);
toast(`Taille du texte : ${{ s: "petite", "": "normale", l: "grande", xl: "très grande" }[next]}`);
}));
$$("[data-action='top']").forEach((b) => b.addEventListener("click", () => scrollTo({ top: 0, behavior: "smooth" })));
// révélation
if ("IntersectionObserver" in window && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } }), { threshold: .08, rootMargin: "0px 0px -40px 0px" });
$$(".reveal").forEach((el) => io.observe(el));
} else $$(".reveal").forEach((el) => el.classList.add("in"));
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function initNav() {
const menuBtn = $(".menu-btn");
const nav = $(".topnav");
if (menuBtn && nav) menuBtn.addEventListener("click", () => { const open = nav.classList.toggle("open"); menuBtn.setAttribute("aria-expanded", String(open)); });
$$(".dd > button").forEach((btn) => {
const dd = btn.parentElement;
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = dd.classList.toggle("open");
btn.setAttribute("aria-expanded", String(open));
$$(".dd.open").forEach((o) => { if (o !== dd) o.classList.remove("open"); });
});
});
document.addEventListener("click", (e) => { if (!e.target.closest(".dd")) $$(".dd.open").forEach((o) => o.classList.remove("open")); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".dd.open").forEach((o) => o.classList.remove("open")); });
const p = progress();
$$(".dd-menu a[data-ch]").forEach((a) => { const ch = p.chapters[a.dataset.ch]; if (ch && ch.done) a.querySelector(".pastille")?.classList.add("done"); });
// barre de défilement globale (topbar) : ombre
const tb = $(".topbar");
if (tb) addEventListener("scroll", () => tb.classList.toggle("scrolled", scrollY > 8), { passive: true });
}
// ---------------------------------------------------------------------------
// Recherche
// ---------------------------------------------------------------------------
const STOP = new Set(["le", "la", "les", "de", "des", "du", "un", "une", "et", "en", "au", "aux", "ou", "que", "qui", "dans", "pour", "par", "sur", "est", "sont", "pas", "ne", "se", "ce", "cette", "ces", "il", "elle", "on", "nous", "vous", "ils", "elles", "avec", "sans", "plus", "moins", "entre", "comme", "mais", "donc", "son", "sa", "ses", "leur", "leurs", "the", "of", "to", "and", "an", "a", "d", "l", "s", "n", "y", "à", "être", "avoir"]);
const tokenize = (s) => norm(s).replace(/[^a-z0-9àâçéèêëîïôûùüÿœ\s-]/gi, " ").split(/[\s-]+/).filter((w) => w.length > 1);
let searchIndex = null;
async function loadSearch() {
if (searchIndex) return searchIndex;
const data = await fetchJSON("/data/search.json");
const inv = new Map();
data.forEach((d, i) => {
const seen = new Set();
tokenize(d.title + " " + d.text).forEach((t) => { if (STOP.has(t) || seen.has(t)) return; seen.add(t); if (!inv.has(t)) inv.set(t, []); inv.get(t).push(i); });
d._normText = norm(d.title + " — " + d.text);
});
searchIndex = { data, inv };
return searchIndex;
}
function search(query, limit = 30) {
const { data, inv } = searchIndex;
const qTokens = tokenize(query).filter((t) => !STOP.has(t));
if (!qTokens.length) return [];
const scores = new Map();
const phrase = norm(query.trim());
qTokens.forEach((qt) => {
for (const [tok, ids] of inv) {
let w = 0;
if (tok === qt) w = 3; else if (tok.startsWith(qt) && qt.length >= 3) w = 1.2; else if (qt.length >= 5 && tok.includes(qt)) w = .6;
if (!w) continue;
ids.forEach((i) => scores.set(i, (scores.get(i) || 0) + w / Math.sqrt(ids.length)));
}
});
const KIND_W = { definition: 1.6, formule: 1.5, aretenir: 1.3, synth: 1.2, gloss: 1.7, exemple: 1, exercice: .9, p: 1, tab: .9, fig: .9, attention: 1.1 };
const out = [];
for (const [i, s] of scores) {
const d = data[i];
let sc = s * (KIND_W[d.kind] || 1);
if (phrase.length > 3 && d._normText.includes(phrase)) sc *= 2.2;
if (norm(d.title).includes(phrase)) sc *= 1.5;
out.push({ d, sc });
}
out.sort((a, b) => b.sc - a.sc);
return out.slice(0, limit).map((o) => o.d);
}
function snippet(text, query, len = 180) {
const n = norm(text);
const q = tokenize(query).filter((t) => !STOP.has(t));
let pos = -1;
for (const t of q) { pos = n.indexOf(t); if (pos >= 0) break; }
if (pos < 0) pos = 0;
const start = Math.max(0, pos - 60);
let s = text.slice(start, start + len);
if (start > 0) s = "…" + s;
if (start + len < text.length) s += "…";
const words = q.filter((t) => t.length > 2).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
if (!words.length) return esc(s);
const re = new RegExp("(" + words.join("|") + ")", "gi");
const pattern = re.source.replace(/e/g, "[eéèêë]").replace(/a/g, "[aàâ]").replace(/i/g, "[iîï]").replace(/o/g, "[oôœ]").replace(/u/g, "[uûü]").replace(/c/g, "[cç]");
return esc(s).replace(new RegExp(pattern, "gi"), "$1");
}
const KIND_LABEL = { definition: "Définition", formule: "Formule", exemple: "Exemple", exercice: "Exercice", aretenir: "À retenir", synth: "Synthèse", p: "Texte", tab: "Tableau", fig: "Figure", gloss: "Glossaire", attention: "Attention", quebec: "Québec", terrain: "Terrain", remarque: "Remarque", video: "Séance vidéo" };
function renderHits(container, hits, query) {
if (!hits.length) { container.innerHTML = `
Aucun résultat pour « ${esc(query)} ».
`; return; }
container.innerHTML = hits.map((d, i) => `${KIND_LABEL[d.kind] || d.kind}${esc(d.title || d.chTitle)}
${snippet(d.text, query)}
${esc(d.where)}
`).join("");
}
function initSearch() {
const modal = $("#search-modal");
if (!modal) return;
const input = $("input", modal);
const results = $(".search-results", modal);
const open = () => { modal.classList.add("open"); input.value = ""; results.innerHTML = 'Tapez un terme : définition, formule, notion, exemple…
'; setTimeout(() => input.focus(), 30); loadSearch(); };
const close = () => modal.classList.remove("open");
$$("[data-action='search']").forEach((b) => b.addEventListener("click", open));
modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); modal.classList.contains("open") ? close() : open(); }
else if (e.key === "/" && !/input|textarea|select/i.test(document.activeElement?.tagName || "") && !modal.classList.contains("open")) { e.preventDefault(); open(); }
else if (e.key === "Escape" && modal.classList.contains("open")) close();
});
const run = debounce(async () => {
const q = input.value.trim();
if (q.length < 2) { results.innerHTML = 'Tapez au moins deux caractères.
'; return; }
await loadSearch();
renderHits(results, search(q), q);
}, 120);
input.addEventListener("input", run);
input.addEventListener("keydown", (e) => {
const hits = $$(".search-hit", results);
const idx = hits.findIndex((h) => h.classList.contains("active"));
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!hits.length) return;
const ni = e.key === "ArrowDown" ? Math.min(hits.length - 1, idx + 1) : Math.max(0, idx - 1);
hits.forEach((h) => h.classList.remove("active"));
hits[ni].classList.add("active");
hits[ni].scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && hits[idx]) location.href = hits[idx].href;
});
const page = $(".search-page");
if (page) {
const pin = $("input", page);
const pres = $(".search-results", page);
const doIt = debounce(async () => {
const q = pin.value.trim();
history.replaceState(null, "", q ? `?q=${encodeURIComponent(q)}` : location.pathname);
if (q.length < 2) { pres.innerHTML = ""; return; }
await loadSearch();
renderHits(pres, search(q, 60), q);
}, 150);
pin.addEventListener("input", doIt);
const q0 = new URLSearchParams(location.search).get("q");
if (q0) { pin.value = q0; doIt(); }
pin.focus();
}
}
// ---------------------------------------------------------------------------
// Infobulles glossaire
// ---------------------------------------------------------------------------
function initGlossaryTips() {
const terms = $$(".motcle");
if (!terms.length) return;
let gl = null, tip = null;
const lookup = (t) => {
const n = norm(t).replace(/[^a-z0-9 ]/g, "").trim();
if (!n) return null;
if (gl.byNorm[n]) return gl.byNorm[n];
const alt = n.endsWith("s") ? n.slice(0, -1) : n + "s";
if (gl.byNorm[alt]) return gl.byNorm[alt];
const found = Object.keys(gl.byNorm).find((k) => k.length > 4 && (n.includes(k) || k.includes(n)));
return found ? gl.byNorm[found] : null;
};
const hide = () => { if (tip) { tip.remove(); tip = null; } };
const show = (el) => {
const e = lookup(el.dataset.term || el.textContent);
if (!e) return;
hide();
tip = document.createElement("div");
tip.className = "tip";
tip.innerHTML = `${esc(e.term)}${e.html}
Voir dans le glossaire →`;
body.appendChild(tip);
const r = el.getBoundingClientRect();
let left = r.left + scrollX;
if (left + tip.offsetWidth > scrollX + innerWidth - 12) left = scrollX + innerWidth - tip.offsetWidth - 12;
tip.style.left = left + "px";
tip.style.top = r.bottom + scrollY + 8 + "px";
};
terms.forEach((el) => {
el.addEventListener("mouseenter", async () => { gl ||= await fetchJSON("/data/glossaire.json"); if (el.matches(":hover")) show(el); });
el.addEventListener("mouseleave", () => setTimeout(() => { if (!tip?.matches(":hover")) hide(); }, 150));
el.addEventListener("click", async (e) => { e.preventDefault(); gl ||= await fetchJSON("/data/glossaire.json"); tip ? hide() : show(el); });
});
document.addEventListener("click", (e) => { if (tip && !e.target.closest(".tip") && !e.target.closest(".motcle")) hide(); });
document.addEventListener("scroll", () => { if (tip) hide(); }, { passive: true });
}
// ---------------------------------------------------------------------------
// Page de séance
// ---------------------------------------------------------------------------
function initChapter() {
const main = $(".chapter-page");
if (!main) return;
const ch = main.dataset.chapter;
const p = progress();
p.chapters[ch] ||= { pct: 0, done: false, visits: 0 };
const st = p.chapters[ch];
st.visits = (st.visits || 0) + 1;
st.visitedAt = Date.now();
p.last = { ch, title: main.dataset.title, url: location.pathname };
saveProgress(p);
const fill = $(".readbar-fill");
const tocBar = $(".toc-progress .bar i");
const tocPct = $(".toc-progress span");
const article = $(".ch-body");
const remaining = $("#read-remaining");
const words = Number(main.dataset.words || 0);
let maxPct = st.pct || 0;
const update = () => {
const rect = article.getBoundingClientRect();
const total = rect.height - innerHeight * .6;
const seen = Math.min(Math.max(-rect.top + innerHeight * .4, 0), total);
const pct = total > 0 ? Math.round((seen / total) * 100) : 100;
if (fill) fill.style.width = pct + "%";
if (remaining) { const m = Math.max(0, Math.round(words * (1 - pct / 100) / 180)); remaining.textContent = m > 0 ? `≈ ${m} min restantes` : "Lecture terminée"; }
if (pct > maxPct) {
maxPct = pct;
const pp = progress();
pp.chapters[ch] = { ...(pp.chapters[ch] || {}), pct: maxPct, done: maxPct >= 92 || (pp.chapters[ch] || {}).done, scroll: scrollY };
saveProgress(pp);
}
if (tocBar) tocBar.style.width = maxPct + "%";
if (tocPct) tocPct.textContent = maxPct + " % lu";
};
let ticking = false;
addEventListener("scroll", () => { if (!ticking) { requestAnimationFrame(() => { update(); ticking = false; }); ticking = true; } }, { passive: true });
update();
addEventListener("scroll", debounce(() => { const pp = progress(); if (pp.chapters[ch]) { pp.chapters[ch].scroll = scrollY; saveProgress(pp); } }, 400), { passive: true });
if (!location.hash && st.scroll && st.scroll > 400 && new URLSearchParams(location.search).get("reprendre") === "1") setTimeout(() => scrollTo({ top: st.scroll, behavior: "auto" }), 50);
// scrollspy sommaire + stepper + sections lues
const links = $$(".ch-toc li a[href^='#'], .stepper a[href^='#']");
const heads = $$(".prose h2.h-section");
const readSections = new Set((p.sections[ch] || []));
const spy = () => {
const y = scrollY + 130;
let cur = null;
heads.forEach((h, i) => { if (h.offsetTop <= y) cur = h; });
$$(".ch-toc li a, .stepper a").forEach((a) => a.classList.remove("active"));
const allH = $$(".prose h2, .prose h3");
let curAny = allH[0];
for (const h of allH) { if (h.offsetTop <= y) curAny = h; else break; }
if (curAny) $$(`.ch-toc li a[href="#${curAny.id}"]`).forEach((a) => a.classList.add("active"));
if (cur) {
$$(`.stepper a[href="#${cur.id}"]`).forEach((a) => a.classList.add("active"));
heads.forEach((h) => { if (h !== cur && h.offsetTop + 200 < y) readSections.add(h.id); });
$$(".stepper a").forEach((a) => a.classList.toggle("done", readSections.has(a.getAttribute("href").slice(1))));
$$(".ch-toc li.lvl2 a").forEach((a) => a.classList.toggle("done", readSections.has(a.getAttribute("href").slice(1))));
}
};
addEventListener("scroll", debounce(() => { spy(); const pp = progress(); pp.sections[ch] = [...readSections]; saveProgress(pp); }, 80), { passive: true });
spy();
// objectifs
const objs = $$(".obj-cb");
const objProg = $(".obj-progress");
const refreshObj = () => { const n = objs.filter((c) => c.checked).length; if (objProg) objProg.textContent = objs.length ? `${n}/${objs.length} atteint${n > 1 ? "s" : ""}` : ""; if (objs.length && n === objs.length && objs.length > 0) $("#objectifs")?.classList.add("complete"); else $("#objectifs")?.classList.remove("complete"); };
objs.forEach((c) => { c.checked = !!p.obj[c.dataset.obj]; c.addEventListener("change", () => { const pp = progress(); pp.obj[c.dataset.obj] = c.checked; saveProgress(pp); refreshObj(); if (objs.every((x) => x.checked)) toast("Tous les objectifs de la séance sont cochés.", "ok"); }); });
refreshObj();
// mode révision
const revBtns = $$("[data-action='revision']");
const setRev = (on) => { body.classList.toggle("revision", on); revBtns.forEach((b) => b.setAttribute("aria-pressed", String(on))); store.setGlobal("revision", on); };
revBtns.forEach((b) => b.addEventListener("click", () => setRev(!body.classList.contains("revision"))));
$("[data-action='revision-off']")?.addEventListener("click", () => setRev(false));
if (store.getGlobal("revision", false)) setRev(true);
$("[data-action='toggle-solutions']")?.addEventListener("click", (e) => {
const dets = $$("details.box-sol");
const anyClosed = dets.some((d) => !d.open);
dets.forEach((d) => (d.open = anyClosed));
e.currentTarget.textContent = anyClosed ? "Replier les solutions" : "Révéler toutes les solutions";
});
document.addEventListener("keydown", (e) => {
if (/input|textarea|select/i.test(document.activeElement?.tagName || "") || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "ArrowRight") { const n = $(".ch-pager a.next"); if (n) location.href = n.href; }
if (e.key === "ArrowLeft") { const n = $(".ch-pager a.prev"); if (n) location.href = n.href; }
});
initHighlights(ch);
initNotes(ch);
initTTS();
Widgets.mountAll();
const qa = $("#quiz-app");
if (qa) Quiz.mount(qa, { chapter: ch, embedded: true });
}
// ---------------------------------------------------------------------------
// Surlignage personnel
// ---------------------------------------------------------------------------
function initHighlights(ch) {
const prose = $(".prose");
if (!prose) return;
let pop = null;
const applyAll = () => {
$$("mark.hl", prose).forEach((m) => { const t = document.createTextNode(m.textContent); m.replaceWith(t); });
prose.normalize();
const p = progress();
p.hl.filter((h) => h.ch === ch).forEach((h) => {
const para = document.getElementById(h.pid);
if (!para) return;
const walker = document.createTreeWalker(para, NodeFilter.SHOW_TEXT);
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
const full = nodes.map((x) => x.data).join("");
const idx = full.indexOf(h.text);
if (idx < 0) return;
let pos = 0;
const end = idx + h.text.length;
nodes.forEach((node) => {
const s = pos, e = pos + node.data.length;
pos = e;
const a = Math.max(idx, s), b = Math.min(end, e);
if (a >= b) return;
const range = document.createRange();
range.setStart(node, a - s);
range.setEnd(node, b - s);
const mark = document.createElement("mark");
mark.className = "hl" + (h.note ? " has-note" : "");
mark.dataset.hl = h.id;
if (h.note) mark.title = h.note;
try { range.surroundContents(mark); } catch (err) { /* chevauchement */ }
});
});
renderNotesList();
};
const hidePop = () => { if (pop) { pop.remove(); pop = null; } };
prose.addEventListener("mouseup", () => setTimeout(() => {
const sel = getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) { hidePop(); return; }
const range = sel.getRangeAt(0);
const para = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer.closest("p[id], li") : range.commonAncestorContainer.parentElement.closest("p[id], li");
if (!para || !prose.contains(para)) { hidePop(); return; }
const pid = para.id || para.closest("p[id], [id]")?.id;
if (!pid) { hidePop(); return; }
const text = sel.toString();
hidePop();
pop = document.createElement("div");
pop.className = "hl-pop";
pop.innerHTML = ``;
body.appendChild(pop);
const r = range.getBoundingClientRect();
pop.style.left = Math.max(8, Math.min(r.left + scrollX + r.width / 2 - pop.offsetWidth / 2, scrollX + innerWidth - pop.offsetWidth - 8)) + "px";
pop.style.top = r.top + scrollY - pop.offsetHeight - 10 + "px";
const save = (note) => {
const p = progress();
p.hl.push({ id: "h" + Date.now(), ch, pid, text, note: note || "", at: Date.now() });
saveProgress(p);
sel.removeAllRanges();
hidePop();
applyAll();
toast("Passage surligné" + (note ? " avec une note." : "."), "ok");
};
$("[data-hl]", pop).addEventListener("click", () => save(""));
$("[data-note]", pop).addEventListener("click", () => { const note = prompt("Votre note sur ce passage :"); if (note !== null) save(note); });
}, 10));
document.addEventListener("mousedown", (e) => { if (pop && !e.target.closest(".hl-pop")) hidePop(); });
prose.addEventListener("click", (e) => {
const m = e.target.closest("mark.hl");
if (!m) return;
const p = progress();
const h = p.hl.find((x) => x.id === m.dataset.hl);
if (!h) return;
const action = prompt(`Passage surligné :\n« ${h.text.slice(0, 120)}${h.text.length > 120 ? "…" : ""} »\n\nModifiez la note (laissez vide pour aucune note, tapez SUPPRIMER pour retirer le surlignage) :`, h.note || "");
if (action === null) return;
if (action.trim().toUpperCase() === "SUPPRIMER") p.hl = p.hl.filter((x) => x.id !== h.id); else h.note = action;
saveProgress(p);
applyAll();
});
applyAll();
}
// ---------------------------------------------------------------------------
// Notes personnelles (panneau latéral)
// ---------------------------------------------------------------------------
function renderNotesList() {
const list = $("#hl-list");
if (!list) return;
const ch = $(".chapter-page")?.dataset.chapter;
const p = progress();
const items = p.hl.filter((h) => h.ch === ch);
list.innerHTML = items.length ? items.map((h) => `« ${esc(h.text.slice(0, 90))}${h.text.length > 90 ? "…" : ""} »${h.note ? `${esc(h.note)}
` : ""}`).join("") : 'Sélectionnez un passage du texte pour le surligner ; il apparaîtra ici.';
$$("[data-del]", list).forEach((b) => b.addEventListener("click", () => { const pp = progress(); pp.hl = pp.hl.filter((x) => x.id !== b.dataset.del); saveProgress(pp); $$("mark.hl").forEach((m) => { if (m.dataset.hl === b.dataset.del) m.replaceWith(document.createTextNode(m.textContent)); }); renderNotesList(); }));
}
function initNotes(ch) {
const panel = $("#notes-panel");
if (!panel) return;
const ta = $("textarea", panel);
const p = progress();
ta.value = p.notes[ch] || "";
ta.addEventListener("input", debounce(() => { const pp = progress(); pp.notes[ch] = ta.value; saveProgress(pp); $("#notes-saved").textContent = "Enregistré"; setTimeout(() => ($("#notes-saved").textContent = ""), 1500); }, 400));
const toggle = (on) => { panel.classList.toggle("open", on); body.classList.toggle("notes-open", on); $$("[data-action='notes']").forEach((b) => b.setAttribute("aria-pressed", String(on))); };
$$("[data-action='notes']").forEach((b) => b.addEventListener("click", () => toggle(!panel.classList.contains("open"))));
$("[data-action='notes-close']", panel)?.addEventListener("click", () => toggle(false));
$("[data-action='notes-export']", panel)?.addEventListener("click", () => {
const pp = progress();
const title = $(".chapter-page").dataset.title;
const hl = pp.hl.filter((h) => h.ch === ch);
let md = `# ${title}\n\n## Mes notes\n\n${pp.notes[ch] || "(aucune)"}\n\n## Passages surlignés\n\n` + (hl.length ? hl.map((h) => `- « ${h.text} »${h.note ? `\n - Note : ${h.note}` : ""}`).join("\n") : "(aucun)") + "\n";
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([md], { type: "text/markdown" }));
a.download = `${COURSE}-seance-${ch}-notes.md`;
a.click();
});
renderNotesList();
}
// ---------------------------------------------------------------------------
// Lecture audio (synthèse vocale)
// ---------------------------------------------------------------------------
function initTTS() {
const btn = $("[data-action='tts']");
if (!btn) return;
if (!("speechSynthesis" in window)) { btn.disabled = true; btn.title = "Synthèse vocale non disponible"; return; }
const bar = $("#tts-bar");
const items = $$(".prose h2, .prose h3, .prose p, .prose li").filter((el) => !el.closest(".sidenote") && el.textContent.trim().length > 2);
let i = 0, playing = false, utter = null;
const voice = () => { const vs = speechSynthesis.getVoices(); return vs.find((v) => v.lang === "fr-CA") || vs.find((v) => v.lang.startsWith("fr")) || null; };
const clear = () => $$(".tts-current").forEach((e) => e.classList.remove("tts-current"));
const speak = () => {
if (i >= items.length) { stop(); toast("Lecture terminée."); return; }
const el = items[i];
clear();
el.classList.add("tts-current");
el.scrollIntoView({ block: "center", behavior: "smooth" });
utter = new SpeechSynthesisUtterance(el.textContent.replace(/\s+/g, " ").trim());
const v = voice();
if (v) utter.voice = v;
utter.lang = "fr-CA";
utter.rate = Number($("#tts-rate")?.value || 1);
utter.onend = () => { if (playing) { i++; speak(); } };
speechSynthesis.speak(utter);
};
const start = () => {
// départ : premier élément visible
const y = scrollY + 140;
const idx = items.findIndex((el) => el.offsetTop >= y);
i = idx >= 0 ? idx : 0;
playing = true;
bar.hidden = false;
$("#tts-play").textContent = "⏸";
speak();
};
const stop = () => { playing = false; speechSynthesis.cancel(); clear(); bar.hidden = true; $("#tts-play").textContent = "▶"; };
btn.addEventListener("click", () => (playing ? stop() : start()));
$("#tts-play")?.addEventListener("click", () => { if (speechSynthesis.speaking && !speechSynthesis.paused) { speechSynthesis.pause(); $("#tts-play").textContent = "▶"; } else if (speechSynthesis.paused) { speechSynthesis.resume(); $("#tts-play").textContent = "⏸"; } else start(); });
$("#tts-stop")?.addEventListener("click", stop);
$("#tts-next")?.addEventListener("click", () => { if (!playing) return; speechSynthesis.cancel(); i++; speak(); });
$("#tts-prev")?.addEventListener("click", () => { if (!playing) return; speechSynthesis.cancel(); i = Math.max(0, i - 1); speak(); });
$("#tts-rate")?.addEventListener("change", () => { if (playing) { speechSynthesis.cancel(); speak(); } });
addEventListener("beforeunload", () => speechSynthesis.cancel());
}
// ---------------------------------------------------------------------------
// Widgets visuels (SVG) insérés dans les séances
// ---------------------------------------------------------------------------
const SVGNS = "http://www.w3.org/2000/svg";
const svgEl = (tag, attrs = {}, text) => { const el = document.createElementNS(SVGNS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); if (text !== undefined) el.textContent = text; return el; };
function chart(w, h) {
const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, class: "wchart", role: "img" });
const add = (tag, attrs, text) => { const el = svgEl(tag, attrs, text); svg.appendChild(el); return el; };
return { svg, add, w, h };
}
const ctrl = (c) => c.type === "select"
? ``
: ``;
const Widgets = {
defs: {},
register(id, def) { this.defs[id] = def; },
mountAll() { $$(".widget[data-widget]").forEach((el) => this.mount(el)); },
mount(el) {
const def = this.defs[el.dataset.widget];
if (!def) { el.remove(); return; }
el.innerHTML = `Explorateur interactif${def.title}
${def.desc}
${def.note ? `${def.note}
` : ""}`;
const form = $("form", el), view = $(".w-view", el);
const values = () => { const v = {}; def.controls.forEach((c) => { const inp = form.elements[c.key]; v[c.key] = c.type === "select" ? inp.value : parseFloat(inp.value); const out = inp.parentElement.querySelector("output"); if (out) out.textContent = (c.fmt ? c.fmt(v[c.key]) : v[c.key]) + (c.unit ? " " + c.unit : ""); }); return v; };
const render = () => { view.innerHTML = ""; def.render(view, values(), el); };
form.addEventListener("input", render);
render();
},
};
const W_CO = { bleu: "var(--bleu)", or: "var(--or)", vert: "var(--vert)", rouge: "var(--rouge)", gris: "var(--gris)", clair: "var(--bleu-clair)", violet: "#6b4fbb", teal: "#0f8b8d" };
function bars(view, rows, opts = {}) {
// rows : [{label, value, color, note}] barres horizontales proportionnelles
const max = opts.max || Math.max(...rows.map((r) => Math.abs(r.value)), 1);
view.insertAdjacentHTML("beforeend", `${rows.map((r) => `
${r.label}${r.fmt ? r.fmt(r.value) : fmtMoney(r.value)}
`).join("")}
`);
}
function kpis(view, list) {
view.insertAdjacentHTML("beforeend", `${list.map((k) => `
${k.label}${k.value}
`).join("")}
`);
}
function lineChart(view, series, opts) {
// series: [{points:[[x,y]], color, label}], opts: {xmin,xmax,ymin,ymax,xfmt,yfmt,marker:[x,y], xlabel, ylabel, w, h}
const w = opts.w || 560, h = opts.h || 260, m = { l: 62, r: 16, t: 14, b: 40 };
const c = chart(w, h);
const X = (x) => m.l + (x - opts.xmin) / (opts.xmax - opts.xmin) * (w - m.l - m.r);
const Y = (y) => h - m.b - (y - opts.ymin) / (opts.ymax - opts.ymin) * (h - m.t - m.b);
for (let i = 0; i <= 4; i++) {
const yv = opts.ymin + (opts.ymax - opts.ymin) * i / 4;
c.add("line", { x1: m.l, x2: w - m.r, y1: Y(yv), y2: Y(yv), class: "grid" });
c.add("text", { x: m.l - 8, y: Y(yv) + 4, class: "tick", "text-anchor": "end" }, opts.yfmt ? opts.yfmt(yv) : fmtNum(yv, 0));
const xv = opts.xmin + (opts.xmax - opts.xmin) * i / 4;
c.add("text", { x: X(xv), y: h - m.b + 18, class: "tick", "text-anchor": "middle" }, opts.xfmt ? opts.xfmt(xv) : fmtNum(xv, 1));
}
c.add("line", { x1: m.l, x2: w - m.r, y1: Y(opts.ymin), y2: Y(opts.ymin), class: "axis" });
c.add("line", { x1: m.l, x2: m.l, y1: Y(opts.ymin), y2: Y(opts.ymax), class: "axis" });
if (opts.xlabel) c.add("text", { x: (m.l + w - m.r) / 2, y: h - 6, class: "alabel", "text-anchor": "middle" }, opts.xlabel);
if (opts.ylabel) c.add("text", { x: 12, y: m.t + 4, class: "alabel" }, opts.ylabel);
series.forEach((s) => {
const d = s.points.map((pt, i) => (i ? "L" : "M") + X(pt[0]).toFixed(1) + " " + Y(Math.max(opts.ymin, Math.min(opts.ymax, pt[1]))).toFixed(1)).join(" ");
c.add("path", { d, fill: "none", stroke: s.color || W_CO.bleu, "stroke-width": 2.5, "stroke-linejoin": "round" });
if (s.label) c.add("text", { x: X(s.points[s.points.length - 1][0]) - 4, y: Y(s.points[s.points.length - 1][1]) - 8, class: "slabel", fill: s.color || W_CO.bleu, "text-anchor": "end" }, s.label);
});
(opts.markers || []).forEach((mk) => {
c.add("line", { x1: X(mk[0]), x2: X(mk[0]), y1: Y(opts.ymin), y2: Y(mk[1]), class: "mline" });
c.add("line", { x1: m.l, x2: X(mk[0]), y1: Y(mk[1]), y2: Y(mk[1]), class: "mline" });
c.add("circle", { cx: X(mk[0]), cy: Y(mk[1]), r: 6, fill: W_CO.or, stroke: "#fff", "stroke-width": 2 });
if (mk[2]) c.add("text", { x: X(mk[0]) + 10, y: Y(mk[1]) - 10, class: "mlabel" }, mk[2]);
});
view.appendChild(c.svg);
return { X, Y, c };
}
function stackBar(view, segments, total, opts = {}) {
// segments: [{label, value, color}] — barre empilée horizontale unique + légende
const sum = total || segments.reduce((a, s) => a + Math.max(0, s.value), 0);
view.insertAdjacentHTML("beforeend", `${segments.map((s) => ``).join("")}
${segments.map((s) => `${s.label} ${opts.fmt ? opts.fmt(s.value) : fmtMoney(s.value)} (${fmtPct(Math.max(0, s.value) / sum * 100, 1)})`).join("")}
`);
}
function waterfall(view, steps, opts = {}) {
// steps: [{label, delta, color, total?}] → SVG cascade
const w = 600, h = 280, m = { l: 20, r: 20, t: 20, b: 70 };
const c = chart(w, h);
let running = 0;
const totals = [];
steps.forEach((s) => { running = s.total !== undefined ? s.total : running + s.delta; totals.push(running); });
const max = Math.max(...totals, ...steps.map((s) => Math.abs(s.delta))) * 1.08;
const bw = (w - m.l - m.r) / steps.length;
const Y = (v) => h - m.b - v / max * (h - m.t - m.b);
let prev = 0;
steps.forEach((s, i) => {
const x = m.l + i * bw + bw * .12;
const cur = totals[i];
let y0, y1;
if (s.total !== undefined) { y0 = Y(0); y1 = Y(cur); } else if (s.delta >= 0) { y0 = Y(prev); y1 = Y(cur); } else { y0 = Y(cur); y1 = Y(prev); }
c.add("rect", { x, y: Math.min(y0, y1), width: bw * .76, height: Math.max(2, Math.abs(y0 - y1)), rx: 4, fill: s.color || (s.total !== undefined ? W_CO.bleu : s.delta >= 0 ? W_CO.vert : W_CO.rouge) });
c.add("text", { x: x + bw * .38, y: Math.min(y0, y1) - 6, class: "vlabel", "text-anchor": "middle" }, (opts.fmt || fmtMoney)(s.total !== undefined ? cur : s.delta));
const lbl = s.label.split("\n");
lbl.forEach((l, k) => c.add("text", { x: x + bw * .38, y: h - m.b + 18 + k * 14, class: "tick", "text-anchor": "middle" }, l));
if (i < steps.length - 1 && steps[i + 1].total === undefined) c.add("line", { x1: x + bw * .76, x2: x + bw + bw * .12, y1: Y(cur), y2: Y(cur), class: "mline" });
prev = cur;
});
c.add("line", { x1: m.l, x2: w - m.r, y1: Y(0), y2: Y(0), class: "axis" });
view.appendChild(c.svg);
}
// --- définitions des widgets ------------------------------------------------
Widgets.register("rpv", {
title: "Prix, évaluation et mise de fonds",
desc: "Déplacez l’évaluation sous le prix accepté : l’écart se répercute entièrement sur la mise de fonds, parce que le prêteur retient la moindre des deux valeurs.",
controls: [{ key: "prix", label: "Prix accepté", min: 250000, max: 900000, step: 5000, default: 450000, fmt: fmtMoney }, { key: "eval", label: "Valeur d’évaluation", min: 250000, max: 900000, step: 5000, default: 430000, fmt: fmtMoney }, { key: "rpv", label: "RPV maximal", min: 50, max: 95, step: 5, default: 80, unit: "%" }],
render(view, v) {
const ret = Math.min(v.prix, v.eval), pret = ret * v.rpv / 100, mise = v.prix - pret;
stackBar(view, [{ label: "Prêt hypothécaire", value: pret, color: W_CO.bleu }, { label: "Mise de fonds", value: mise, color: W_CO.or }], v.prix);
kpis(view, [{ label: "Valeur retenue", value: fmtMoney(ret) }, { label: "Mise de fonds", value: fmtMoney(mise), cls: "main" }, { label: "En % du prix", value: fmtPct(mise / v.prix * 100, 1), cls: mise / v.prix > .2 + 1e-9 ? "warn" : "" }, { label: "Assurance prêt", value: v.rpv > 80 ? "obligatoire" : "non requise", cls: v.rpv > 80 ? "warn" : "good" }]);
},
});
Widgets.register("mutation", {
title: "Les tranches des droits de mutation",
desc: "Le barème est progressif par tranches : chaque portion du prix est taxée à son propre taux. Faites glisser le prix et observez la part de chaque tranche.",
controls: [{ key: "prix", label: "Base d’imposition", min: 50000, max: 1500000, step: 5000, default: 477000, fmt: fmtMoney }, { key: "muni", label: "Taux municipal majoré au-delà de 500 000 $", min: 0, max: 3, step: .5, default: 0, unit: "%" }],
render(view, v) {
const b = v.prix;
const t1 = Math.min(b, 62900) * .005, t2 = Math.max(0, Math.min(b, 315000) - 62900) * .01;
let t3, t4 = 0;
if (v.muni > 0 && b > 500000) { t3 = Math.max(0, 500000 - 315000) * .015; t4 = (b - 500000) * v.muni / 100; } else t3 = Math.max(0, b - 315000) * .015;
const segs = [{ label: "0,5 % (≤ 62 900 $)", value: t1, color: W_CO.vert }, { label: "1,0 % (62 900 – 315 000 $)", value: t2, color: W_CO.clair }, { label: "1,5 % (> 315 000 $)", value: t3, color: W_CO.bleu }];
if (t4) segs.push({ label: `${v.muni} % municipal (> 500 000 $)`, value: t4, color: W_CO.rouge });
stackBar(view, segs, null, { fmt: fmtMoney2 });
kpis(view, [{ label: "Droits exigibles", value: fmtMoney2(t1 + t2 + t3 + t4), cls: "main" }, { label: "Taux effectif", value: fmtPct((t1 + t2 + t3 + t4) / b * 100) }, { label: "Taux marginal", value: fmtPct(b > 500000 && v.muni ? v.muni : b > 315000 ? 1.5 : b > 62900 ? 1 : .5, 1) }]);
},
});
Widgets.register("offre-demande", {
title: "Offre inélastique et choc de demande",
desc: "L’offre de logements réagit lentement (pente raide à court terme). Déplacez la demande : le prix d’équilibre bouge beaucoup, la quantité peu. Assouplissez l’offre pour voir l’effet à long terme.",
controls: [{ key: "dem", label: "Déplacement de la demande", min: -30, max: 30, step: 1, default: 0, unit: "%" }, { key: "elas", label: "Élasticité de l’offre", min: 1, max: 10, step: 1, default: 2 }, { key: "off", label: "Déplacement de l’offre (mises en chantier)", min: -20, max: 20, step: 1, default: 0, unit: "%" }],
render(view, v) {
const P = (q) => Math.max(0, 900 - 6 * q); // demande de base
const dem = (q) => P(q) * (1 + v.dem / 100);
const supp = (q) => 60 + (q - v.off) * (60 / v.elas);
// équilibre : dem(q) = supp(q)
let qe = 0; for (let q = 0; q <= 150; q += .25) { if (dem(q) <= supp(q)) { qe = q; break; } }
const pe = supp(qe);
const q0 = (() => { let q = 0; for (let x = 0; x <= 150; x += .25) { if (P(x) <= 60 + x * 30) { q = x; break; } } return q; })();
const p0 = 60 + q0 * 30;
lineChart(view, [{ points: Array.from({ length: 61 }, (_, i) => [i * 2.5, dem(i * 2.5)]), color: W_CO.bleu, label: "Demande" }, { points: Array.from({ length: 61 }, (_, i) => [i * 2.5, supp(i * 2.5)]), color: W_CO.or, label: "Offre" }],
{ xmin: 0, xmax: 150, ymin: 0, ymax: 1000, xfmt: (x) => fmtNum(x, 0), yfmt: (y) => fmtNum(y, 0) + " k$", xlabel: "Quantité de logements (indice)", ylabel: "Prix", markers: [[qe, pe, `P* = ${fmtNum(pe, 0)} k$`]] });
kpis(view, [{ label: "Prix d’équilibre", value: fmtNum(pe, 0) + " k$", cls: "main" }, { label: "Variation vs référence", value: fmtPct((pe / p0 - 1) * 100, 1), cls: pe > p0 ? "warn" : "good" }, { label: "Quantité d’équilibre", value: fmtNum(qe, 1) }, { label: "Variation de quantité", value: fmtPct((qe / q0 - 1) * 100, 1) }]);
},
note: "Illustration stylisée : à Gatineau, une hausse de la demande de 2020 à 2022 s’est traduite par +40 à 50 % de prix, l’offre ne pouvant suivre à court terme.",
});
Widgets.register("cycle", {
title: "Le cycle immobilier et ses indicateurs",
desc: "Parcourez le cycle : à chaque phase correspondent des indicateurs typiques (mois d’inventaire, délai de vente, ratio vendu/demandé, mises en chantier).",
controls: [{ key: "t", label: "Position dans le cycle", min: 0, max: 100, step: 1, default: 20, unit: "%" }],
render(view, v) {
const phases = [
{ n: "Reprise", from: 0, to: 25, txt: "Les ventes repartent, l’inventaire fond, les délais se raccourcissent ; les prix commencent à monter. Mises en chantier encore faibles.", ind: ["Mois d’inventaire : 6 → 4", "Délai de vente : en baisse", "Ratio vendu/demandé : 97–99 %", "Mises en chantier : faibles, en hausse"] },
{ n: "Expansion / sommet", from: 25, to: 50, txt: "Marché de vendeurs : surenchères, inventaire < 4 mois, délais très courts ; la construction s’emballe avec retard.", ind: ["Mois d’inventaire : < 4", "Délai de vente : très court", "Ratio vendu/demandé : > 100 %", "Mises en chantier : record"] },
{ n: "Contraction", from: 50, to: 75, txt: "La demande fléchit (taux, accessibilité) alors que l’offre nouvelle arrive : l’inventaire gonfle, les prix stagnent puis reculent.", ind: ["Mois d’inventaire : 5 → 8", "Délai de vente : s’allonge", "Ratio vendu/demandé : 95–97 %", "Mises en chantier : en chute"] },
{ n: "Creux", from: 75, to: 100, txt: "Marché d’acheteurs : inventaire > 8 mois, négociation, faillites de promoteurs ; la rareté future se prépare.", ind: ["Mois d’inventaire : > 8", "Délai de vente : long", "Ratio vendu/demandé : < 95 %", "Mises en chantier : minimales"] },
];
const ph = phases.find((p) => v.t >= p.from && v.t < p.to) || phases[3];
const pts = Array.from({ length: 101 }, (_, i) => [i, 50 + 40 * Math.sin((i / 100) * 2 * Math.PI - Math.PI / 2)]);
const cur = 50 + 40 * Math.sin((v.t / 100) * 2 * Math.PI - Math.PI / 2);
const { X, Y, c } = lineChart(view, [{ points: pts, color: W_CO.bleu }], { xmin: 0, xmax: 100, ymin: 0, ymax: 100, xfmt: (x) => ["Reprise", "Sommet", "Contraction", "Creux", ""][Math.round(x / 25)] || "", yfmt: () => "", xlabel: "Temps (7 à 12 ans par cycle)", ylabel: "Prix / activité", markers: [[v.t, cur, ph.n]] });
phases.forEach((p, i) => c.add("rect", { x: X(p.from), y: Y(100), width: X(p.to) - X(p.from), height: Y(0) - Y(100), fill: i % 2 ? "rgba(41,118,187,.05)" : "rgba(204,164,36,.06)" }));
view.insertAdjacentHTML("beforeend", `${ph.n}
${ph.txt}
${ph.ind.map((x) => `- ${x}
`).join("")}
`);
},
});
Widgets.register("seuils", {
title: "Ajustements net et brut : les seuils de prudence",
desc: "Réglez les quatre ajustements d’un comparable. Le net additionne les signes (ils se compensent), le brut additionne les valeurs absolues. Repères : net ≤ 15 %, brut ≤ 25 %.",
controls: [{ key: "prix", label: "Prix du comparable", min: 250000, max: 900000, step: 5000, default: 450000, fmt: fmtMoney }, { key: "a", label: "Temps", min: -40000, max: 40000, step: 1000, default: 12000, fmt: fmtMoney }, { key: "b", label: "Localisation", min: -40000, max: 40000, step: 1000, default: -15000, fmt: fmtMoney }, { key: "c", label: "Superficie", min: -40000, max: 40000, step: 1000, default: 20000, fmt: fmtMoney }, { key: "d", label: "Garage / extras", min: -40000, max: 40000, step: 1000, default: -8000, fmt: fmtMoney }],
render(view, v) {
const adj = [v.a, v.b, v.c, v.d];
const net = adj.reduce((s, x) => s + x, 0), brut = adj.reduce((s, x) => s + Math.abs(x), 0);
const np = Math.abs(net) / v.prix * 100, bp = brut / v.prix * 100;
view.insertAdjacentHTML("beforeend", `${[["Ajustement net", np, 15], ["Ajustement brut", bp, 25]].map(([l, val, seuil]) => `
${l}${fmtPct(val, 1)}
seuil ${seuil} %`).join("")}
`);
kpis(view, [{ label: "Prix ajusté", value: fmtMoney(v.prix + net), cls: "main" }, { label: "Net", value: fmtMoney(net) }, { label: "Brut", value: fmtMoney(brut) }, { label: "Fiabilité", value: np <= 15 && bp <= 25 ? "acceptable" : "réduite — justifier ou écarter", cls: np <= 15 && bp <= 25 ? "good" : "warn" }]);
},
});
Widgets.register("age-vie", {
title: "Dépréciation selon l’âge effectif",
desc: "La méthode âge/vie fait croître la dépréciation linéairement avec l’âge effectif. Modifiez l’entretien (âge effectif) et la vie économique : la courbe et le point se déplacent.",
controls: [{ key: "cn", label: "Coût neuf", min: 200000, max: 3000000, step: 10000, default: 600000, fmt: fmtMoney }, { key: "cur", label: "Entretien différé (curable)", min: 0, max: 100000, step: 1000, default: 10000, fmt: fmtMoney }, { key: "age", label: "Âge effectif", min: 0, max: 70, step: 1, default: 18, unit: "ans" }, { key: "vie", label: "Vie économique", min: 30, max: 90, step: 5, default: 60, unit: "ans" }],
render(view, v) {
const base = v.cn - v.cur;
const D = (a) => (v.cur + base * Math.min(1, a / v.vie)) / v.cn * 100;
lineChart(view, [{ points: Array.from({ length: 71 }, (_, a) => [a, D(a)]), color: W_CO.bleu, label: "% du coût neuf" }], { xmin: 0, xmax: 70, ymin: 0, ymax: 100, xfmt: (x) => fmtNum(x, 0) + " ans", yfmt: (y) => fmtNum(y, 0) + " %", xlabel: "Âge effectif", ylabel: "Dépréciation", markers: [[Math.min(70, v.age), D(v.age), fmtPct(D(v.age), 1)]] });
const inc = base * Math.min(1, v.age / v.vie);
kpis(view, [{ label: "Curable", value: fmtMoney(v.cur) }, { label: "Incurable (âge/vie)", value: fmtMoney(inc) }, { label: "Dépréciation totale", value: fmtMoney(v.cur + inc), cls: "main" }, { label: "Valeur dépréciée", value: fmtMoney(v.cn - v.cur - inc), cls: "good" }, { label: "Vie restante", value: Math.max(0, v.vie - v.age) + " ans" }]);
},
});
Widgets.register("cascade-revenus", {
title: "La cascade des revenus, du RBP à la valeur",
desc: "Chaque étape retranche ou ajoute : inoccupation, revenus accessoires, dépenses. Le RNE est ensuite capitalisé. Observez comme le ratio de dépenses pèse sur la valeur.",
controls: [{ key: "n", label: "Logements", min: 2, max: 24, step: 1, default: 6 }, { key: "loyer", label: "Loyer mensuel moyen", min: 700, max: 2200, step: 25, default: 1050, fmt: fmtMoney }, { key: "inocc", label: "Inoccupation et mauvaises créances", min: 0, max: 10, step: .5, default: 4, unit: "% du RBP" }, { key: "ratio", label: "Dépenses d’exploitation", min: 20, max: 60, step: 1, default: 40, unit: "% du RBE" }, { key: "tga", label: "TGA", min: 4, max: 9, step: .25, default: 5.5, unit: "%" }],
render(view, v) {
const rbp = v.n * v.loyer * 12, inocc = rbp * v.inocc / 100, autres = v.n * 300, rbe = rbp - inocc + autres, dep = rbe * v.ratio / 100, rne = rbe - dep;
waterfall(view, [{ label: "RBP", total: rbp, color: W_CO.bleu }, { label: "Inoccupation", delta: -inocc }, { label: "Revenus\naccessoires", delta: autres }, { label: "RBE", total: rbe, color: W_CO.clair }, { label: "Dépenses", delta: -dep }, { label: "RNE", total: rne, color: W_CO.vert }]);
kpis(view, [{ label: "RNE", value: fmtMoney(rne) }, { label: `Valeur (RNE ÷ ${fmtNum(v.tga, 2)} %)`, value: fmtMoney(rne / (v.tga / 100)), cls: "main" }, { label: "Valeur par logement", value: fmtMoney(rne / (v.tga / 100) / v.n) }, { label: "Ratio dépenses/RBE", value: fmtPct(v.ratio, 0), cls: v.ratio < 35 || v.ratio > 45 ? "warn" : "good" }]);
},
});
Widgets.register("sensibilite-tga", {
title: "La valeur réagit au taux global d’actualisation",
desc: "V = RNE ÷ TGA : la relation est hyperbolique. Un demi-point de TGA déplace la valeur de 7 à 10 %. Glissez le taux pour mesurer la sensibilité.",
controls: [{ key: "rne", label: "RNE stabilisé", min: 30000, max: 600000, step: 5000, default: 128000, fmt: fmtMoney }, { key: "tga", label: "TGA retenu", min: 4, max: 9, step: .25, default: 5.5, unit: "%" }],
render(view, v) {
const V = (t) => v.rne / (t / 100);
lineChart(view, [{ points: Array.from({ length: 41 }, (_, i) => [4 + i * .125, V(4 + i * .125)]), color: W_CO.bleu, label: "Valeur" }], { xmin: 4, xmax: 9, ymin: 0, ymax: V(4) * 1.05, xfmt: (x) => fmtNum(x, 2) + " %", yfmt: (y) => fmtNum(y / 1000, 0) + " k$", xlabel: "TGA", ylabel: "Valeur", markers: [[v.tga, V(v.tga), fmtMoney(V(v.tga))]] });
kpis(view, [{ label: "Valeur", value: fmtMoney(V(v.tga)), cls: "main" }, { label: "TGA − 0,5 pt", value: fmtMoney(V(v.tga - .5)) + ` (${fmtPct((V(v.tga - .5) / V(v.tga) - 1) * 100, 1)})`, cls: "good" }, { label: "TGA + 0,5 pt", value: fmtMoney(V(v.tga + .5)) + ` (${fmtPct((V(v.tga + .5) / V(v.tga) - 1) * 100, 1)})`, cls: "warn" }, { label: "MRN (1/TGA)", value: fmtNum(100 / v.tga, 2) }]);
},
});
Widgets.register("dcf", {
title: "Anatomie d’un DCF : flux actualisés et réversion",
desc: "Chaque barre est la valeur présente d’un RNE annuel ; la dernière est la réversion actualisée. Voyez le poids de la réversion et l’effet du taux d’actualisation.",
controls: [{ key: "rne", label: "RNE année 1", min: 50000, max: 500000, step: 5000, default: 128000, fmt: fmtMoney }, { key: "g", label: "Croissance du RNE", min: 0, max: 5, step: .25, default: 2.5, unit: "%/an" }, { key: "n", label: "Détention", min: 3, max: 10, step: 1, default: 5, unit: "ans" }, { key: "r", label: "Taux d’actualisation", min: 5, max: 12, step: .25, default: 7.5, unit: "%" }, { key: "tgaout", label: "TGA de sortie", min: 4, max: 9, step: .25, default: 5.75, unit: "%" }],
render(view, v) {
const r = v.r / 100, g = v.g / 100;
let rne = v.rne, pv = 0;
const rows = [];
for (let t = 1; t <= v.n; t++) { const f = rne / Math.pow(1 + r, t); pv += f; rows.push({ label: `Année ${t}`, value: f, color: W_CO.bleu }); rne *= 1 + g; }
const rev = rne / (v.tgaout / 100) * .97, pvRev = rev / Math.pow(1 + r, v.n);
rows.push({ label: "Réversion (nette 3 %)", value: pvRev, color: W_CO.or });
bars(view, rows);
kpis(view, [{ label: "VA des flux", value: fmtMoney(pv) }, { label: "VA de la réversion", value: fmtMoney(pvRev) }, { label: "Valeur DCF", value: fmtMoney(pv + pvRev), cls: "main" }, { label: "Poids de la réversion", value: fmtPct(pvRev / (pv + pvRev) * 100, 0), cls: pvRev / (pv + pvRev) > .75 ? "warn" : "" }]);
},
});
Widgets.register("cascade-cout", {
title: "Des coûts directs au coût neuf total",
desc: "Les coûts indirects s’expriment en pourcentage des directs ; le profit entrepreneurial s’applique sur la somme des deux. Le coût neuf dépasse typiquement les directs de 30 à 40 %.",
controls: [{ key: "d", label: "Coûts directs", min: 200000, max: 5000000, step: 10000, default: 1350000, fmt: fmtMoney }, { key: "ind", label: "Coûts indirects", min: 5, max: 35, step: 1, default: 19, unit: "% des directs" }, { key: "prof", label: "Profit entrepreneurial", min: 0, max: 25, step: 1, default: 12, unit: "% de (directs + indirects)" }],
render(view, v) {
const i = v.d * v.ind / 100, p = (v.d + i) * v.prof / 100;
waterfall(view, [{ label: "Coûts directs", total: v.d, color: W_CO.bleu }, { label: "Indirects", delta: i, color: W_CO.clair }, { label: "Sous-total", total: v.d + i, color: W_CO.teal }, { label: "Profit", delta: p, color: W_CO.or }, { label: "Coût neuf", total: v.d + i + p, color: W_CO.vert }]);
kpis(view, [{ label: "Coût neuf total", value: fmtMoney(v.d + i + p), cls: "main" }, { label: "Majoration sur directs", value: fmtPct((i + p) / v.d * 100, 1), cls: (i + p) / v.d < .28 || (i + p) / v.d > .45 ? "warn" : "good" }, { label: "Part des indirects", value: fmtPct(i / (v.d + i + p) * 100, 1) }, { label: "Part du profit", value: fmtPct(p / (v.d + i + p) * 100, 1) }]);
},
});
Widgets.register("hbu", {
title: "Le filtre du plus haut et meilleur usage",
desc: "Quatre critères appliqués dans l’ordre : légal, physique, financier, puis productivité maximale. Modifiez le zonage et le marché pour voir quel usage survit.",
controls: [{ key: "zon", label: "Zonage", type: "select", default: "res", options: [{ value: "res", label: "Résidentiel faible densité" }, { value: "mixte", label: "Mixte (commercial + résidentiel)" }, { value: "ind", label: "Industriel léger" }] }, { key: "marche", label: "Vigueur du marché", min: 60, max: 140, step: 5, default: 100, unit: "% (indice)" }, { key: "sol", label: "Capacité portante du sol", type: "select", default: "bon", options: [{ value: "bon", label: "Bon sol" }, { value: "argile", label: "Argile sensible (fondations coûteuses)" }] }],
render(view, v) {
const uses = [
{ n: "Unifamiliale", legal: ["res", "mixte"], phys: true, resid: 120000, coutSol: 0 },
{ n: "Triplex", legal: ["res", "mixte"], phys: true, resid: 165000, coutSol: 15000 },
{ n: "Immeuble de 6 logements", legal: ["mixte"], phys: true, resid: 240000, coutSol: 45000 },
{ n: "Commerce de détail + logements", legal: ["mixte"], phys: true, resid: 290000, coutSol: 60000 },
{ n: "Entrepôt", legal: ["ind"], phys: true, resid: 130000, coutSol: 30000 },
{ n: "Tour de 12 étages", legal: ["mixte"], phys: false, resid: 900000, coutSol: 400000 },
];
const rows = uses.map((u) => {
const legal = u.legal.includes(v.zon);
const phys = u.phys && !(v.sol === "argile" && u.n.includes("Tour"));
const resid = u.resid * v.marche / 100 - (v.sol === "argile" ? u.coutSol : 0);
const fin = resid > 0;
return { ...u, legal, phys, fin, resid, ok: legal && phys && fin };
});
const best = rows.filter((r) => r.ok).sort((a, b) => b.resid - a.resid)[0];
view.insertAdjacentHTML("beforeend", `| Usage candidat | Légal | Physique | Financier | Valeur résiduelle du terrain |
${rows.map((r) => `| ${r.n}${r === best ? ' PHMU' : ""} | ${r.legal ? "✓" : "✗"} | ${r.legal ? (r.phys ? "✓" : "✗") : "—"} | ${r.legal && r.phys ? (r.fin ? "✓" : "✗") : "—"} | ${r.legal && r.phys ? fmtMoney(r.resid) : "—"} |
`).join("")}
`);
kpis(view, [{ label: "Plus haut et meilleur usage", value: best ? best.n : "aucun usage viable", cls: "main" }, { label: "Valeur du terrain (résiduelle)", value: best ? fmtMoney(best.resid) : "—" }]);
},
note: "Données illustratives. Le critère légal borne tout : un usage plus rentable mais interdit par le zonage n’est pas le PHMU.",
});
Widgets.register("harper", {
title: "La règle 4-3-2-1 de Harper",
desc: "La valeur d’un lot se concentre côté rue : 40 % dans le premier quart de profondeur, puis 30, 20 et 10 %. Faites varier la profondeur du lot sujet.",
controls: [{ key: "vstd", label: "Valeur du lot standard", min: 50000, max: 500000, step: 5000, default: 180000, fmt: fmtMoney }, { key: "pstd", label: "Profondeur standard", min: 60, max: 240, step: 10, default: 120, unit: "pi" }, { key: "p", label: "Profondeur du lot sujet", min: 20, max: 240, step: 5, default: 90, unit: "pi" }, { key: "regle", label: "Règle", type: "select", default: "h", options: [{ value: "h", label: "Harper 40-30-20-10" }, { value: "d", label: "Davis 50-25-15-10" }] }],
render(view, v) {
const parts = v.regle === "d" ? [.5, .25, .15, .1] : [.4, .3, .2, .1];
const q = v.pstd / 4;
let ratio = 0;
const w = 560, h = 230, c = chart(w, h);
const scale = (w - 80) / Math.max(v.pstd, v.p);
for (let i = 0; i < 4; i++) {
const cov = Math.max(0, Math.min(q, v.p - i * q)) / q;
ratio += cov * parts[i];
const x = 40 + i * q * scale;
c.add("rect", { x, y: 40, width: q * scale - 2, height: 110, rx: 3, fill: `rgba(41,118,187,${.15 + parts[i] * 1.4})` });
if (cov > 0) c.add("rect", { x, y: 40, width: cov * q * scale - 2, height: 110, rx: 3, fill: "none", stroke: W_CO.or, "stroke-width": 3 });
c.add("text", { x: x + q * scale / 2, y: 100, class: "vlabel", "text-anchor": "middle" }, fmtPct(parts[i] * 100, 0));
c.add("text", { x: x + q * scale / 2, y: 170, class: "tick", "text-anchor": "middle" }, `${Math.round(i * q)}–${Math.round((i + 1) * q)} pi`);
}
c.add("rect", { x: 20, y: 30, width: 16, height: 130, fill: W_CO.gris, opacity: .5 });
c.add("text", { x: 28, y: 190, class: "tick", "text-anchor": "middle" }, "rue");
const extra = v.p > v.pstd ? Math.min(2, (v.p - v.pstd) / q) * .05 : 0;
ratio += extra;
view.appendChild(c.svg);
kpis(view, [{ label: "Part de la valeur standard", value: fmtPct(ratio * 100, 1) }, { label: "Valeur du lot sujet", value: fmtMoney(v.vstd * ratio), cls: "main" }, { label: "Valeur par pied de profondeur", value: fmtMoney2(v.vstd * ratio / v.p) }]);
},
note: "Au-delà de la profondeur standard, le surplus est valorisé prudemment (5 % par quart supplémentaire, maximum deux).",
});
Widgets.register("indexation", {
title: "Indexer un coût avec l’IPCB",
desc: "L’indice des prix de la construction de bâtiments (Statistique Canada, base 2023 = 100) permet de ramener un coût connu à la date d’évaluation. Choisissez les deux dates.",
controls: [{ key: "c0", label: "Coût connu", min: 100000, max: 5000000, step: 10000, default: 1500000, fmt: fmtMoney }, { key: "y0", label: "Année du coût connu", min: 2015, max: 2026, step: 1, default: 2019 }, { key: "y1", label: "Année d’évaluation", min: 2015, max: 2026, step: 1, default: 2026 }],
render(view, v) {
const idx = { 2015: 62.1, 2016: 63.9, 2017: 66.4, 2018: 69.5, 2019: 71.6, 2020: 74.3, 2021: 88.4, 2022: 100.8, 2023: 100, 2024: 102.9, 2025: 105.5, 2026: 108.7 };
const f = idx[v.y1] / idx[v.y0];
lineChart(view, [{ points: Object.entries(idx).map(([y, i]) => [Number(y), i]), color: W_CO.bleu, label: "IPCB" }], { xmin: 2015, xmax: 2026, ymin: 50, ymax: 115, xfmt: (x) => String(Math.round(x)), yfmt: (y) => fmtNum(y, 0), xlabel: "Année", ylabel: "Indice (2023 = 100)", markers: [[v.y0, idx[v.y0], String(v.y0)], [v.y1, idx[v.y1], String(v.y1)]] });
kpis(view, [{ label: "Facteur d’indexation", value: fmtNum(f, 4) }, { label: "Coût indexé", value: fmtMoney(v.c0 * f), cls: "main" }, { label: "Variation", value: fmtPct((f - 1) * 100, 1), cls: f > 1 ? "warn" : "good" }]);
},
note: "Valeurs d’indice illustratives, calées sur le profil réel (+19 % en 2021, +14 % en 2022, puis ≈ +3 %/an). Toujours utiliser le tableau 18-10-0289-01 à jour.",
});
Widgets.register("ventilation", {
title: "Ventiler la dépréciation physique",
desc: "Curable d’abord, puis les composantes à courte durée de vie chacune selon son âge/vie, puis le résidu long terme. La barre montre la décomposition du coût neuf.",
controls: [{ key: "cn", label: "Coût neuf", min: 500000, max: 5000000, step: 10000, default: 1800000, fmt: fmtMoney }, { key: "cur", label: "Entretien différé", min: 0, max: 200000, step: 5000, default: 45000, fmt: fmtMoney }, { key: "ctc", label: "Coût neuf des composantes CT", min: 100000, max: 1500000, step: 10000, default: 560000, fmt: fmtMoney }, { key: "ctr", label: "Âge/vie moyen des composantes CT", min: 0, max: 100, step: 5, default: 60, unit: "%" }, { key: "age", label: "Âge effectif du bâtiment", min: 0, max: 60, step: 1, default: 27, unit: "ans" }, { key: "vie", label: "Vie économique", min: 30, max: 80, step: 5, default: 50, unit: "ans" }],
render(view, v) {
const ct = Math.min(v.ctc, v.cn - v.cur) * v.ctr / 100;
const base = Math.max(0, v.cn - v.cur - Math.min(v.ctc, v.cn - v.cur));
const lt = base * Math.min(1, v.age / v.vie);
const reste = v.cn - v.cur - ct - lt;
stackBar(view, [{ label: "Curable", value: v.cur, color: W_CO.rouge }, { label: "Incurable CT", value: ct, color: W_CO.or }, { label: "Incurable LT", value: lt, color: W_CO.bleu }, { label: "Valeur restante", value: reste, color: W_CO.vert }], v.cn);
kpis(view, [{ label: "Dépréciation physique", value: fmtMoney(v.cur + ct + lt), cls: "main" }, { label: "En % du coût neuf", value: fmtPct((v.cur + ct + lt) / v.cn * 100, 1) }, { label: "Base long terme", value: fmtMoney(base) }, { label: "Âge/vie simple (comparaison)", value: fmtMoney(v.cn * Math.min(1, v.age / v.vie)) }]);
},
});
Widgets.register("allocation", {
title: "Allouer la dépréciation externe",
desc: "L’externe frappe le terrain et les améliorations. Seule la part des améliorations se déduit du coût neuf déprécié ; la part terrain est déjà dans la valeur du terrain.",
controls: [{ key: "perte", label: "Perte de revenu net annuelle", min: 0, max: 100000, step: 1000, default: 12000, fmt: fmtMoney }, { key: "tga", label: "TGA", min: 5, max: 10, step: .25, default: 7, unit: "%" }, { key: "vt", label: "Valeur du terrain", min: 50000, max: 1000000, step: 10000, default: 292000, fmt: fmtMoney }, { key: "vam", label: "Valeur des améliorations", min: 100000, max: 3000000, step: 10000, default: 845000, fmt: fmtMoney }],
render(view, v) {
const d = v.perte / (v.tga / 100), ra = v.vam / (v.vam + v.vt);
const w = 560, h = 220, c = chart(w, h), cx = 120, cy = 110, r = 80;
const a = ra * 2 * Math.PI;
const arc = (a0, a1, col) => { const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0), x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1); c.add("path", { d: `M${cx} ${cy} L${x0} ${y0} A${r} ${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1} ${y1} Z`, fill: col }); };
arc(-Math.PI / 2, -Math.PI / 2 + a, W_CO.bleu);
arc(-Math.PI / 2 + a, Math.PI * 1.5, W_CO.or);
c.add("circle", { cx, cy, r: 46, fill: "var(--card)" });
c.add("text", { x: cx, y: cy + 5, class: "vlabel", "text-anchor": "middle" }, fmtPct(ra * 100, 0));
c.add("rect", { x: 240, y: 60, width: 14, height: 14, fill: W_CO.bleu }); c.add("text", { x: 262, y: 72, class: "tick" }, `Améliorations : ${fmtMoney(d * ra)} déduits du coût déprécié`);
c.add("rect", { x: 240, y: 100, width: 14, height: 14, fill: W_CO.or }); c.add("text", { x: 262, y: 112, class: "tick" }, `Terrain : ${fmtMoney(d * (1 - ra))} déjà dans V_T`);
view.appendChild(c.svg);
kpis(view, [{ label: "Dépréciation externe totale", value: fmtMoney(d) }, { label: "Part améliorations à déduire", value: fmtMoney(d * ra), cls: "main" }, { label: "Ratio améliorations", value: fmtPct(ra * 100, 1) }]);
},
});
Widgets.register("bail-vie", {
title: "Améliorations locatives : le bail limite la vie utile",
desc: "La vie utile retenue est le minimum de la vie physique et de la durée de bail restante (options probables incluses). Faites varier le bail.",
controls: [{ key: "c", label: "Coût neuf des améliorations", min: 20000, max: 800000, step: 5000, default: 180000, fmt: fmtMoney }, { key: "age", label: "Âge", min: 0, max: 15, step: 1, default: 3, unit: "ans" }, { key: "vph", label: "Vie physique", min: 5, max: 25, step: 1, default: 15, unit: "ans" }, { key: "bail", label: "Bail restant", min: 1, max: 20, step: 1, default: 4, unit: "ans" }],
render(view, v) {
const vie = Math.min(v.vph, v.age + v.bail);
const rows = [{ label: "Vie physique", value: v.vph, color: W_CO.gris, fmt: (x) => x + " ans" }, { label: "Âge + bail restant", value: v.age + v.bail, color: W_CO.or, fmt: (x) => x + " ans" }, { label: "Vie utile retenue", value: vie, color: W_CO.vert, fmt: (x) => x + " ans" }, { label: "Âge écoulé", value: v.age, color: W_CO.rouge, fmt: (x) => x + " ans" }];
bars(view, rows, { max: Math.max(v.vph, v.age + v.bail) });
const dep = Math.min(1, v.age / vie);
kpis(view, [{ label: "Dépréciation", value: fmtPct(dep * 100, 1) }, { label: "Valeur dépréciée", value: fmtMoney(v.c * (1 - dep)), cls: "main" }, { label: "Dépréciation annuelle", value: fmtMoney(v.c / vie) }, { label: "Facteur limitant", value: v.vph <= v.age + v.bail ? "vie physique" : "bail", cls: v.vph <= v.age + v.bail ? "" : "warn" }]);
},
});
// ---------------------------------------------------------------------------
// Quiz
// ---------------------------------------------------------------------------
const Quiz = {
data: null,
async load() { this.data ||= await fetchJSON("/data/quiz.json"); return this.data; },
async mount(root, opts) {
const data = await this.load();
if (opts.chapter) {
const qs = data.chapters[opts.chapter] || [];
if (!qs.length) { root.closest(".ch-quiz")?.remove(); return; }
this.run(root, qs, { title: `Testez-vous — séance ${Number(opts.chapter)}`, key: opts.chapter, embedded: opts.embedded });
return;
}
this.menu(root, data);
},
menu(root, data) {
const p = progress();
const items = Object.keys(data.chapters).sort().map((k) => {
const best = p.quiz[k]?.best, n = data.chapters[k].length;
return ``;
}).join("");
const total = Object.values(data.chapters).reduce((a, b) => a + b.length, 0);
const bestExam = p.quiz.exam?.best;
root.innerHTML = `
${items}
${total} questions au total, avec rétroaction et renvoi vers la section des notes.
`;
$$("button[data-ch]", root).forEach((b) => b.addEventListener("click", () => { const k = b.dataset.ch; this.run(root, data.chapters[k], { title: `Séance ${Number(k)} — ${data.titles[k] || ""}`, key: k, back: () => this.menu(root, data) }); }));
$$("button[data-exam]", root).forEach((b) => b.addEventListener("click", () => {
const mode = b.dataset.exam;
let pool = [];
Object.keys(data.chapters).forEach((k) => { const n = Number(k); if (mode === "bloc1" && n > 7) return; if (mode === "bloc2" && n < 8) return; pool = pool.concat(data.chapters[k].map((q) => ({ ...q, ch: k }))); });
const qs = shuffle(pool).slice(0, mode === "1" ? 25 : 20);
this.run(root, qs, { title: mode === "1" ? "Examen blanc" : b.querySelector("b").textContent, key: mode === "1" ? "exam" : "bloc-" + mode, timer: mode === "1" ? 1800 : 1200, back: () => this.menu(root, data) });
}));
},
run(root, questions, opts) {
const qs = questions.map((q) => ({ ...q, order: shuffle(q.c.map((_, i) => i)), answer: null }));
let i = 0, score = 0, timerId = null, remaining = opts.timer || 0;
const letters = "ABCDEF";
const fmtT = () => `${String(Math.floor(remaining / 60)).padStart(2, "0")}:${String(remaining % 60).padStart(2, "0")}`;
const stopTimer = () => { if (timerId) clearInterval(timerId); timerId = null; };
const render = () => {
const q = qs[i];
root.innerHTML = `
${opts.embedded ? `
${esc(opts.title)}
` : `
${esc(opts.title)}
`}
Question ${i + 1}/${qs.length}Score : ${score}${opts.timer ? `${fmtT()}` : ""}
${q.ch ? `Séance ${Number(q.ch)} · ` : ""}Question ${i + 1}
${q.q}
${q.order.map((ci, k) => ``).join("")}
${opts.back ? `
` : ""}${q.url ? `
Relire la section →` : ""}
`;
const fb = $(".q-feedback", root), next = $("[data-next]", root);
$$(".q-choice", root).forEach((btn) => btn.addEventListener("click", () => {
if (q.answer !== null) return;
const ci = Number(btn.dataset.ci);
q.answer = ci;
const ok = ci === q.a;
if (ok) score++;
$$(".q-choice", root).forEach((b) => { b.disabled = true; const c = Number(b.dataset.ci); if (c === q.a) b.classList.add("correct"); else if (c === ci) b.classList.add("wrong"); });
fb.className = "q-feedback show " + (ok ? "ok" : "ko");
fb.innerHTML = `${ok ? "Bonne réponse." : "Réponse inexacte."}${q.e || ""}`;
next.disabled = false;
$(".quiz-score b", root).textContent = score;
next.focus();
}));
next.addEventListener("click", () => { i++; if (i < qs.length) render(); else finish(); });
$("[data-back]", root)?.addEventListener("click", () => { stopTimer(); opts.back(); });
document.addEventListener("keydown", keyHandler);
if (opts.timer && !timerId) timerId = setInterval(() => { remaining--; const e2 = $("[data-timer]", root); if (e2) e2.textContent = fmtT(); if (remaining <= 0) { stopTimer(); finish(true); } }, 1000);
};
const keyHandler = (e) => {
if (!root.isConnected) { document.removeEventListener("keydown", keyHandler); return; }
if (/input|textarea|select/i.test(document.activeElement?.tagName || "")) return;
const idx = letters.indexOf(e.key.toUpperCase());
const btns = $$(".q-choice", root);
if (idx >= 0 && btns[idx] && !btns[idx].disabled) btns[idx].click();
if (e.key === "Enter") { const n = $("[data-next]", root); if (n && !n.disabled) n.click(); }
};
const finish = (timeout) => {
stopTimer();
document.removeEventListener("keydown", keyHandler);
const pct = Math.round((score / qs.length) * 100);
const pp = progress();
const prev = pp.quiz[opts.key] || {};
pp.quiz[opts.key] = { best: Math.max(prev.best || 0, score), last: score, total: qs.length, at: Date.now(), attempts: (prev.attempts || 0) + 1 };
if (/^\d+$/.test(opts.key) && pct >= 70) pp.chapters[opts.key] = { ...(pp.chapters[opts.key] || {}), done: true };
saveProgress(pp);
if (pct >= 90) confetti();
const verdict = pct >= 90 ? "Excellent — matière maîtrisée." : pct >= 70 ? "Bien — quelques points à revoir." : pct >= 50 ? "Passable — relisez la fiche synthèse." : "Insuffisant — reprenez la séance avant de continuer.";
root.innerHTML = `${timeout ? "Temps écoulé · " : ""}Résultat
${score}/${qs.length}
${verdict}
${pct} % de bonnes réponses${prev.best !== undefined ? ` · meilleur score précédent : ${prev.best}/${prev.total}` : ""}
${opts.back ? `` : ""}
${qs.map((q) => `
${q.answer === q.a ? "✓" : "✗"}${q.q}
${q.answer !== q.a ? `
Réponse : ${q.c[q.a]}` : ""}${q.url ? `
→ notes` : ""}
`).join("")}
`;
$("[data-retry]", root).addEventListener("click", () => { remaining = opts.timer || 0; this.run(root, questions, opts); });
$("[data-back]", root)?.addEventListener("click", () => opts.back());
renderProgressWidgets();
};
render();
},
};
// ---------------------------------------------------------------------------
// Fiches
// ---------------------------------------------------------------------------
async function initFlashcards() {
const root = $("#flash-app");
if (!root) return;
const cards = await fetchJSON("/data/cards.json");
const p = progress();
const boxes = p.cards || {};
const chSel = $("#fc-ch"), typeSel = $("#fc-type"), modeSel = $("#fc-mode");
const chapters = [...new Set(cards.map((c) => c.ch))].sort((a, b) => (a === "G" ? 1 : b === "G" ? -1 : Number(a) - Number(b)));
chSel.innerHTML = `` + chapters.map((c) => ``).join("");
const q0 = new URLSearchParams(location.search).get("ch");
if (q0 && chapters.includes(q0)) chSel.value = q0;
let deck = [], idx = 0;
const TYPE_LABEL = { def: "Définition", gloss: "Glossaire", form: "Formule" };
const renderStats = () => {
const all = cards.filter((c) => (!chSel.value || c.ch === chSel.value) && (!typeSel.value || c.type === typeSel.value));
const acq = all.filter((c) => (boxes[c.id] || 0) >= 3).length, enc = all.filter((c) => (boxes[c.id] || 0) > 0 && boxes[c.id] < 3).length, rev = all.filter((c) => boxes[c.id] === 0).length;
$("#fc-stats").innerHTML = `${all.length} fiches${acq} acquises${enc} en cours${rev} à revoir${all.length - acq - enc - rev} jamais vues`;
};
const renderCard = () => {
const stage = $("#fc-stage");
if (!deck.length) { stage.innerHTML = `Aucune fiche dans cette sélection. Changez les filtres ou le mode.
`; $("#fc-nav").textContent = ""; return; }
const c = deck[idx];
stage.innerHTML = `${TYPE_LABEL[c.type]}${c.ch === "G" ? "Glossaire" : "Séance " + Number(c.ch)}
${c.front}
Cliquer ou appuyer sur Espace pour retourner
${TYPE_LABEL[c.type]}${c.num || ""}
${c.back}
`;
const card = $(".fc-card", stage);
card.addEventListener("click", (e) => { if (!e.target.closest("a")) card.classList.toggle("flipped"); });
$("#fc-nav").innerHTML = `Fiche ${idx + 1} / ${deck.length}Niveau : ${"●".repeat(boxes[c.id] || 0)}${"○".repeat(3 - (boxes[c.id] || 0))}`;
};
const filterDeck = () => {
const mode = modeSel.value;
deck = cards.filter((c) => (!chSel.value || c.ch === chSel.value) && (!typeSel.value || c.type === typeSel.value));
if (mode === "review") deck = deck.filter((c) => (boxes[c.id] || 0) < 3);
if (mode === "hard") deck = deck.filter((c) => boxes[c.id] === 0);
deck = shuffle(deck); idx = 0; renderStats(); renderCard();
};
const grade = (ok) => {
if (!deck.length) return;
const c = deck[idx];
boxes[c.id] = ok ? Math.min(3, (boxes[c.id] || 0) + 1) : 0;
const pp = progress(); pp.cards = boxes; saveProgress(pp);
idx = (idx + 1) % deck.length; renderStats(); renderCard();
};
$("#fc-ok").addEventListener("click", () => grade(true));
$("#fc-ko").addEventListener("click", () => grade(false));
$("#fc-flip").addEventListener("click", () => $(".fc-card")?.classList.toggle("flipped"));
$("#fc-skip").addEventListener("click", () => { if (deck.length) { idx = (idx + 1) % deck.length; renderCard(); } });
$("#fc-reset").addEventListener("click", () => { if (confirm("Réinitialiser la progression des fiches ?")) { for (const k in boxes) delete boxes[k]; const pp = progress(); pp.cards = {}; saveProgress(pp); filterDeck(); } });
[chSel, typeSel, modeSel].forEach((s) => s.addEventListener("change", filterDeck));
document.addEventListener("keydown", (e) => {
if (/input|textarea|select/i.test(document.activeElement?.tagName || "")) return;
if (e.key === " ") { e.preventDefault(); $(".fc-card")?.classList.toggle("flipped"); }
if (e.key === "ArrowRight" || e.key.toLowerCase() === "j") grade(true);
if (e.key === "ArrowLeft" || e.key.toLowerCase() === "r") grade(false);
});
filterDeck();
}
// ---------------------------------------------------------------------------
// Calculateurs
// ---------------------------------------------------------------------------
function initTools() {
const root = $("#tools-app");
if (!root || !window.UQO_TOOLS) return;
const tools = window.UQO_TOOLS;
const nav = $("#tools-nav");
if (nav) nav.innerHTML = tools.map((t) => `${esc(t.title)}`).join("");
const F = { money: fmtMoney, money2: fmtMoney2, pct: fmtPct, num: fmtNum, int: (v) => fmtNum(v, 0), text: (v) => v, pct1: (v) => fmtPct(v, 1), pct3: (v) => fmtPct(v, 3), num4: (v) => fmtNum(v, 4) };
root.innerHTML = tools.map((t) => ``).join("");
tools.forEach((t) => {
const form = $(`form[data-tool="${t.id}"]`, root), out = $(`#${t.id} .tool-results`, root);
const compute = () => {
const v = {};
t.inputs.forEach((inp) => { if (inp.key) { const el = form.elements[inp.key]; v[inp.key] = inp.type === "select" ? el.value : parseFloat(String(el.value).replace(",", ".")); } });
let res;
try { res = t.compute(v); } catch (e) { res = [{ label: "Erreur", value: String(e.message), fmt: "text", cls: "warn" }]; }
out.innerHTML = res.map((r) => {
if (r.table) return ``;
const f = F[r.fmt || "money"] || fmtMoney;
return `${r.label}${f(r.value)}
`;
}).join("");
};
form.addEventListener("input", compute);
form.addEventListener("submit", (e) => e.preventDefault());
compute();
});
$$(".reveal", root).forEach((el) => el.classList.add("in"));
}
// ---------------------------------------------------------------------------
// Entraînement : problèmes générés
// ---------------------------------------------------------------------------
function initTraining() {
const root = $("#training-app");
if (!root || !window.UQO_TRAINING) return;
const gens = window.UQO_TRAINING;
let current = null, gen = null, mode = "mix", streak = 0;
const stats = () => progress().training || {};
const menu = $("#training-menu");
const renderMenu = () => {
const s = stats();
menu.innerHTML = `` + gens.map((g) => { const st = s[g.id] || { ok: 0, n: 0 }; return ``; }).join("");
$$("[data-g]", menu).forEach((b) => b.addEventListener("click", () => { mode = b.dataset.g; renderMenu(); next(); }));
};
const next = () => {
gen = mode === "mix" ? R.pick(gens) : gens.find((g) => g.id === mode);
current = gen.gen(R);
root.innerHTML = `Séance ${Number(gen.ch)} · ${esc(gen.title)} · Série : ${streak}
${current.enonce}
`;
const form = $("form", root), input = $("input", root), fb = $(".q-feedback", root);
input.focus();
let answered = false;
form.addEventListener("submit", (e) => {
e.preventDefault();
if (answered) { next(); return; }
const v = parseNumber(input.value);
if (!isFinite(v)) { fb.className = "q-feedback show ko"; fb.innerHTML = "Entrez un nombre. Les espaces, le symbole $ ou % et la virgule décimale sont acceptés."; return; }
const ok = Math.abs(v - current.answer) <= Math.max(Math.abs(current.answer) * current.tol, current.unit === "%" ? 0.02 : 1);
answered = true;
const p = progress();
p.training[gen.id] = { ok: (p.training[gen.id]?.ok || 0) + (ok ? 1 : 0), n: (p.training[gen.id]?.n || 0) + 1 };
saveProgress(p);
streak = ok ? streak + 1 : 0;
fb.className = "q-feedback show " + (ok ? "ok" : "ko");
fb.innerHTML = `${ok ? "Exact." : "Inexact."} Réponse attendue : ${current.unit === "%" ? fmtPct(current.answer) : fmtMoney2(current.answer)}${ok ? "" : ` (vous avez répondu ${current.unit === "%" ? fmtPct(v) : fmtMoney2(v)}).`}${current.steps}
`;
$("button[type=submit]", form).textContent = "Problème suivant →";
if (ok && streak > 0 && streak % 5 === 0) { toast(`Série de ${streak} bonnes réponses !`, "ok"); confetti(); }
renderMenu();
$(".streak", root).textContent = `Série : ${streak}`;
});
$("[data-sol]", root).addEventListener("click", () => { answered = true; streak = 0; fb.className = "q-feedback show"; fb.innerHTML = `Solution. Réponse : ${current.unit === "%" ? fmtPct(current.answer) : fmtMoney2(current.answer)}${current.steps}
`; $("button[type=submit]", form).textContent = "Problème suivant →"; });
$("[data-skip]", root).addEventListener("click", next);
};
renderMenu();
next();
}
// ---------------------------------------------------------------------------
// Jeux de révision
// ---------------------------------------------------------------------------
async function initGames() {
const root = $("#games-app");
if (!root) return;
const cards = await fetchJSON("/data/cards.json");
const seqs = window.UQO_SEQUENCES || [];
const tabs = $$(".game-tabs button");
const saveGame = (key, res) => { const p = progress(); const g = p.games[key] || { played: 0, best: 0, total: 0 }; g.played++; g.best = Math.max(g.best, res.score); g.total = res.total; g.last = res.score; saveProgress(p); p.games[key] = g; saveProgress(p); };
const plain = (h) => { const d = document.createElement("div"); d.innerHTML = h; return d.textContent.replace(/\s+/g, " ").trim(); };
const pool = (type) => cards.filter((c) => (type ? c.type === type : c.type !== "form") && plain(c.back).length > 30);
const assoc = () => {
const picks = shuffle(pool()).slice(0, 6);
const rights = shuffle(picks);
let sel = null, matched = 0, errors = 0;
const t0 = Date.now();
root.innerHTML = `Associez chaque terme à sa définition
6 paires · 0 erreur(s)${picks.map((c, i) => ``).join("")}
${rights.map((c) => ``).join("")}
`;
const onPick = (btn) => {
if (btn.classList.contains("ok")) return;
if (btn.dataset.l !== undefined) { $$(".assoc-item[data-l]", root).forEach((b) => b.classList.remove("sel")); btn.classList.add("sel"); sel = Number(btn.dataset.l); return; }
if (sel === null) return;
const card = picks[sel];
if (btn.dataset.r === card.id) {
btn.classList.add("ok"); $(`.assoc-item[data-l="${sel}"]`, root).classList.add("ok");
matched++; sel = null;
if (matched === 6) { const secs = Math.round((Date.now() - t0) / 1000); const score = Math.max(0, 6 - errors); saveGame("assoc", { score, total: 6 }); root.insertAdjacentHTML("beforeend", `${score}/6
Terminé en ${secs} s avec ${errors} erreur(s).
`); $("[data-again]", root).addEventListener("click", assoc); if (errors === 0) confetti(); }
} else { errors++; $("#g-err").textContent = errors; btn.classList.add("ko"); setTimeout(() => btn.classList.remove("ko"), 500); }
};
$$(".assoc-item", root).forEach((b) => b.addEventListener("click", () => onPick(b)));
};
const vraiFaux = () => {
const src = shuffle(pool()).slice(0, 20);
const rounds = [];
for (let i = 0; i < 10; i++) {
const c = src[i];
const wrong = Math.random() < .5;
const other = wrong ? src[10 + i] : null;
rounds.push({ term: c.front, def: wrong ? other.back : c.back, truth: !wrong, realTerm: other ? other.front : null, url: c.url });
}
let i = 0, score = 0;
const render = () => {
const r = rounds[i];
root.innerHTML = `Vrai ou faux ?
Question ${i + 1}/10 · score ${score}Cette définition correspond-elle au terme ?
${r.term}
${r.def}
`;
$$("[data-v]", root).forEach((b) => b.addEventListener("click", () => {
const ans = b.dataset.v === "1";
const ok = ans === r.truth;
if (ok) score++;
$$("[data-v]", root).forEach((x) => (x.disabled = true));
const fb = $(".q-feedback", root);
fb.className = "q-feedback show " + (ok ? "ok" : "ko");
fb.innerHTML = `${ok ? "Exact." : "Inexact."} ${r.truth ? "La définition correspond bien au terme." : `Cette définition est celle de ${r.realTerm}.`} Voir dans les notes →`;
const n = $("[data-next]", root); n.hidden = false; n.focus();
n.addEventListener("click", () => { i++; if (i < 10) render(); else { saveGame("vf", { score, total: 10 }); root.innerHTML = `${score}/10
${score >= 8 ? "Très bien !" : score >= 6 ? "Bien." : "À retravailler."}
`; $("[data-again]", root).addEventListener("click", vraiFaux); if (score === 10) confetti(); } });
}));
};
render();
};
const ordre = () => {
if (!seqs.length) { root.innerHTML = "Aucune séquence disponible.
"; return; }
const seq = R.pick(seqs);
const items = shuffle(seq[1].map((s, i) => ({ s, i })));
let chosen = [];
const render = () => {
root.innerHTML = `Remettez dans l’ordre
${esc(seq[0])}${items.filter((it) => !chosen.includes(it)).map((it) => ``).join("") || 'Tous les éléments sont placés.'}
${chosen.map((it, k) => `- ${it.s}
`).join("")}
`;
$$(".ordre-item", root).forEach((b) => b.addEventListener("click", () => { chosen.push(items.find((it) => it.i === Number(b.dataset.i))); render(); }));
$$("[data-undo]", root).forEach((b) => b.addEventListener("click", () => { chosen.splice(Number(b.dataset.undo), 1); render(); }));
$("[data-other]", root).addEventListener("click", ordre);
$("[data-check]", root).addEventListener("click", () => {
const correct = chosen.filter((it, k) => it.i === k).length;
const fb = $(".q-feedback", root);
fb.className = "q-feedback show " + (correct === items.length ? "ok" : "ko");
fb.innerHTML = `${correct}/${items.length} en bonne position.${correct === items.length ? " Séquence parfaite." : ` Ordre correct : ${seq[1].map((s) => `- ${s}
`).join("")}
`}`;
$$(".ordre-list li", root).forEach((li, k) => li.classList.add(chosen[k].i === k ? "ok" : "ko"));
saveGame("ordre", { score: correct, total: items.length });
if (correct === items.length) confetti();
});
};
render();
};
const modes = { assoc, vf: vraiFaux, ordre };
tabs.forEach((t) => t.addEventListener("click", () => { tabs.forEach((x) => x.setAttribute("aria-selected", String(x === t))); modes[t.dataset.game](); }));
(modes[new URLSearchParams(location.search).get("jeu")] || assoc)();
}
// ---------------------------------------------------------------------------
// Progression (tableau de bord)
// ---------------------------------------------------------------------------
function initProgressPage() {
const root = $("#progress-app");
if (!root || !window.UQO_META) return;
const meta = window.UQO_META;
const render = () => {
const p = progress();
const done = meta.chapters.filter((c) => p.chapters[c.n]?.done).length;
const readAvg = Math.round(meta.chapters.reduce((a, c) => a + (p.chapters[c.n]?.pct || 0), 0) / meta.chapters.length);
const quizzes = Object.entries(p.quiz).filter(([k]) => /^\d+$/.test(k));
const quizAvg = quizzes.length ? Math.round(quizzes.reduce((a, [, q]) => a + q.best / q.total, 0) / quizzes.length * 100) : null;
const objTotal = meta.chapters.reduce((a, c) => a + c.objectives, 0);
const objDone = Object.values(p.obj).filter(Boolean).length;
const cardsAcq = Object.values(p.cards).filter((b) => b >= 3).length;
const tr = Object.values(p.training);
const trN = tr.reduce((a, t) => a + t.n, 0), trOk = tr.reduce((a, t) => a + t.ok, 0);
root.innerHTML = `
${done}/14séances terminées
${readAvg} %lecture moyenne
${quizAvg === null ? "—" : quizAvg + " %"}quiz : moyenne des meilleurs scores (${quizzes.length} séance${quizzes.length > 1 ? "s" : ""})
${objDone}/${objTotal}objectifs cochés
${cardsAcq}/${meta.cards}fiches acquises
${trN ? Math.round(trOk / trN * 100) + " %" : "—"}entraînement : ${trOk} justes sur ${trN}
${p.hl.length}passages surlignés · ${Object.values(p.notes).filter((n) => n && n.trim()).length} séance(s) avec notes
${Object.values(p.games).reduce((a, g) => a + g.played, 0)}parties jouées (association, vrai/faux, ordre)
Séance par séance
| Séance | Lecture | Quiz (meilleur) | Objectifs | Sections lues | |
${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 `| ${Number(c.n)} ${esc(c.title)} | ${ch.pct || 0} % | ${q ? ` ${q.best}/${q.total} · ${q.attempts || 1} essai(s)` : 'non tenté'} | ${objs}/${c.objectives} | ${secs}/${c.sections} | ${ch.done ? 'terminée' : ch.pct ? 'en cours' : ""} |
`; }).join("")}
`;
$("[data-export]", root).addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify(progress(), null, 1)], { type: "application/json" })); a.download = `${COURSE}-progression.json`; a.click(); });
$("[data-import]", root).addEventListener("change", (e) => { const f = e.target.files[0]; if (!f) return; f.text().then((t) => { try { const d = JSON.parse(t); if (!d.chapters) throw 0; saveProgress({ ...DEFAULT_PROGRESS, ...d }); toast("Progression importée.", "ok"); render(); } catch (err) { toast("Fichier invalide.", "ko"); } }); });
$("[data-notes-export]", root).addEventListener("click", () => {
const pp = progress();
let md = `# ${meta.course} — mes notes\n\n`;
meta.chapters.forEach((c) => { const n = pp.notes[c.n], hl = pp.hl.filter((h) => h.ch === c.n); if (!n && !hl.length) return; md += `## Séance ${Number(c.n)} — ${c.title}\n\n${n ? n + "\n\n" : ""}${hl.map((h) => `- « ${h.text} »${h.note ? `\n - Note : ${h.note}` : ""}`).join("\n")}\n\n`; });
const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([md], { type: "text/markdown" })); a.download = `${COURSE}-notes.md`; a.click();
});
$("[data-reset]", root).addEventListener("click", () => { if (confirm("Effacer toute la progression de ce cours sur cet appareil (lecture, quiz, fiches, notes, surlignages) ?")) { saveProgress({ ...DEFAULT_PROGRESS }); render(); toast("Progression réinitialisée."); } });
};
render();
}
// ---------------------------------------------------------------------------
// Carte du cours
// ---------------------------------------------------------------------------
function initMap() {
const map = $(".course-map");
if (!map || !window.UQO_MAP) return;
const panel = $("#map-panel");
const p = progress();
$$("[data-node]", map).forEach((g) => {
const n = g.dataset.node;
const ch = p.chapters[n];
if (ch?.done) g.classList.add("done"); else if (ch?.pct) g.classList.add("started");
const show = () => {
const d = window.UQO_MAP.chapters[n];
if (!panel || !d) return;
$$("[data-node]", map).forEach((x) => x.classList.remove("active"));
g.classList.add("active");
const q = p.quiz[n];
panel.innerHTML = `Séance ${Number(n)} · Partie ${d.part}
${d.title}
${d.counts.map((c) => `${c}`).join("")}${ch?.pct ? `${ch.pct} % lu` : ""}${q ? `quiz ${q.best}/${q.total}` : ""}
Objectifs d’apprentissage
${d.objectives.map((o) => `- ${o}
`).join("")}
`;
};
g.addEventListener("mouseenter", show);
g.addEventListener("focus", show);
g.addEventListener("click", (e) => { if (e.target.closest("a")) return; show(); });
});
const first = $("[data-node]", map);
if (first && panel) first.dispatchEvent(new Event("mouseenter"));
}
// ---------------------------------------------------------------------------
// Glossaire, diapositives, sommaire latéral, ancres
// ---------------------------------------------------------------------------
function initGlossaryPage() {
const input = $("#gl-search");
if (!input) return;
const entries = $$(".gl-entry"), letters = $$(".gl-letter"), count = $("#gl-count");
const run = () => {
const q = norm(input.value.trim());
let n = 0;
const seen = new Set();
entries.forEach((e) => { const hit = !q || norm(e.textContent).includes(q); e.classList.toggle("hidden", !hit); if (hit) { n++; seen.add(e.dataset.letter); } });
letters.forEach((l) => l.classList.toggle("hidden", !seen.has(l.dataset.letter)));
$$(".gl-letters a").forEach((a) => a.classList.toggle("off", !seen.has(a.textContent.trim())));
if (count) count.textContent = q ? `${n} terme${n > 1 ? "s" : ""} correspondant${n > 1 ? "s" : ""}` : `${entries.length} termes`;
};
input.addEventListener("input", debounce(run, 80));
const q0 = new URLSearchParams(location.search).get("q");
if (q0) input.value = q0;
run();
}
function initSlides() {
const tabs = $$(".slide-tabs button"), frame = $(".slide-viewer iframe"), title = $("#slide-title"), dl = $("#slide-download");
if (!tabs.length || !frame) return;
const select = (b) => { tabs.forEach((t) => t.setAttribute("aria-selected", String(t === b))); frame.src = b.dataset.src + "#view=FitH"; if (title) title.textContent = b.dataset.title; if (dl) dl.href = b.dataset.src; history.replaceState(null, "", "#" + b.dataset.n); };
tabs.forEach((b) => b.addEventListener("click", () => select(b)));
const h = location.hash.replace("#", "");
select(tabs.find((t) => t.dataset.n === h) || tabs[0]);
}
function initSideSpy() {
const links = $$(".two-col .side li a[href^='#']");
if (!links.length) return;
const heads = links.map((a) => document.getElementById(a.getAttribute("href").slice(1))).filter(Boolean);
const spy = () => { let cur = heads[0]; const y = scrollY + 120; for (const h of heads) { if (h.offsetTop <= y) cur = h; else break; } links.forEach((a) => a.classList.toggle("active", cur && a.getAttribute("href") === "#" + cur.id)); };
addEventListener("scroll", debounce(spy, 60), { passive: true });
spy();
}
function initHashDetails() {
const openTarget = () => { if (!location.hash) return; const el = document.getElementById(decodeURIComponent(location.hash.slice(1))); if (!el) return; const det = el.closest("details"); if (det) det.open = true; el.classList.add("flash-target"); setTimeout(() => el.classList.remove("flash-target"), 2500); };
addEventListener("hashchange", openTarget);
openTarget();
}
function renderProgressWidgets() {
const p = progress();
$$("[data-progress-ch]").forEach((el) => { const ch = p.chapters[el.dataset.progressCh] || {}; const q = p.quiz[el.dataset.progressCh]; const pct = Math.max(ch.pct || 0, ch.done ? 100 : 0); el.style.setProperty("--p", pct); el.dataset.done = ch.done ? "1" : "0"; el.title = `${pct} % lu${q ? ` · quiz ${q.best}/${q.total}` : ""}`; });
$$("[data-quiz-ch]").forEach((el) => { const q = p.quiz[el.dataset.quizCh]; if (q) { el.textContent = `Quiz ${q.best}/${q.total}`; el.classList.add(q.best / q.total >= .7 ? "chip-vert" : "chip-or"); el.hidden = false; } });
const resume = $("#resume-card");
if (resume) {
const done = Object.values(p.chapters).filter((c) => c.done).length;
const total = Number(resume.dataset.total || 14);
const pct = Math.round((done / total) * 100);
$(".bar i", resume).style.width = pct + "%";
$(".resume-pct", resume).textContent = `${done}/${total} séances terminées · ${pct} %`;
if (p.last) { $(".resume-title", resume).textContent = p.last.title || `Séance ${Number(p.last.ch)}`; const a = $(".resume-link", resume); a.href = p.last.url + "?reprendre=1"; a.textContent = "Reprendre la lecture"; }
const quizzes = Object.entries(p.quiz).filter(([k]) => /^\d+$/.test(k));
if (quizzes.length) { const sum = quizzes.reduce((a, [, q]) => a + q.best / q.total, 0) / quizzes.length; $(".resume-quiz", resume).textContent = `Quiz : ${quizzes.length} séance${quizzes.length > 1 ? "s" : ""} évaluée${quizzes.length > 1 ? "s" : ""}, moyenne ${Math.round(sum * 100)} %`; }
}
$$(".timeline-item[data-ch]").forEach((el) => { const ch = p.chapters[el.dataset.ch]; if (ch?.done) el.classList.add("done"); else if (ch?.pct) el.classList.add("started"); });
}
// ---------------------------------------------------------------------------
// Explorateurs de données du rapport (HPI ACI, FRED)
// ---------------------------------------------------------------------------
const PALETTE = ["#0f6180", "#cca424", "#b22a20", "#107c4e", "#6b4fbb", "#2a86b0", "#e07b39", "#0f8b8d", "#8a6d3b", "#555"];
function seriesChart(host, series, opts) {
// series: [{label, color, points:[[x(index), y]]}], xLabels: [], hover tooltip
const w = 760, h = 340, m = { l: 62, r: 16, t: 14, b: 42 };
const c = chart(w, h);
const xs = opts.xLabels;
const all = series.flatMap((s) => s.points.map((p) => p[1])).filter((v) => v !== null && isFinite(v));
let ymin = Math.min(...all), ymax = Math.max(...all);
if (opts.zeroLine) { ymin = Math.min(ymin, 0); ymax = Math.max(ymax, 0); }
const pad = (ymax - ymin) * .08 || 1;
ymin -= pad; ymax += pad;
const X = (i) => m.l + (i / Math.max(1, xs.length - 1)) * (w - m.l - m.r);
const Y = (y) => h - m.b - (y - ymin) / (ymax - ymin) * (h - m.t - m.b);
for (let i = 0; i <= 5; i++) { const yv = ymin + (ymax - ymin) * i / 5; c.add("line", { x1: m.l, x2: w - m.r, y1: Y(yv), y2: Y(yv), class: "grid" }); c.add("text", { x: m.l - 8, y: Y(yv) + 4, class: "tick", "text-anchor": "end" }, opts.yfmt(yv)); }
const step = Math.max(1, Math.round(xs.length / 8));
for (let i = 0; i < xs.length; i += step) c.add("text", { x: X(i), y: h - m.b + 18, class: "tick", "text-anchor": "middle" }, xs[i].slice(0, 4));
if (opts.zeroLine) c.add("line", { x1: m.l, x2: w - m.r, y1: Y(0), y2: Y(0), class: "axis" });
c.add("line", { x1: m.l, x2: m.l, y1: Y(ymin), y2: Y(ymax), class: "axis" });
series.forEach((s) => {
let d = "", pen = false;
s.points.forEach(([i, v]) => { if (v === null || !isFinite(v)) { pen = false; return; } d += (pen ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " "; pen = true; });
c.add("path", { d, fill: "none", stroke: s.color, "stroke-width": 2.2, "stroke-linejoin": "round" });
});
const cursor = c.add("line", { x1: 0, x2: 0, y1: Y(ymin), y2: Y(ymax), class: "mline", opacity: 0 });
const dots = series.map((s) => c.add("circle", { r: 5, fill: s.color, stroke: "#fff", "stroke-width": 2, opacity: 0 }));
host.appendChild(c.svg);
const tip = document.createElement("div"); tip.className = "hpi-tip"; tip.style.display = "none"; host.appendChild(tip);
c.svg.addEventListener("mousemove", (ev) => {
const r = c.svg.getBoundingClientRect();
const px = (ev.clientX - r.left) / r.width * w;
const i = Math.max(0, Math.min(xs.length - 1, Math.round((px - m.l) / (w - m.l - m.r) * (xs.length - 1))));
cursor.setAttribute("x1", X(i)); cursor.setAttribute("x2", X(i)); cursor.setAttribute("opacity", 1);
const rows = [];
series.forEach((s, k) => { const v = s.points[i]?.[1]; if (v === null || v === undefined || !isFinite(v)) { dots[k].setAttribute("opacity", 0); return; } dots[k].setAttribute("cx", X(i)); dots[k].setAttribute("cy", Y(v)); dots[k].setAttribute("opacity", 1); rows.push(`■ ${s.label} : ${opts.yfmt(v)}`); });
tip.innerHTML = `${xs[i]}
${rows.join("
")}`;
tip.style.display = "block";
tip.style.left = (X(i) / w * r.width) + "px";
tip.style.top = (ev.clientY - r.top - 8) + "px";
tip.style.transform = X(i) > w * .7 ? "translate(-100%, -110%)" : X(i) < w * .3 ? "translate(0, -110%)" : "translate(-50%, -110%)";
});
c.svg.addEventListener("mouseleave", () => { cursor.setAttribute("opacity", 0); dots.forEach((d) => d.setAttribute("opacity", 0)); tip.style.display = "none"; });
}
async function initHPI() {
const host = $("#hpi-explorer");
if (!host) return;
const data = await fetchJSON(host.dataset.src);
const names = Object.keys(data.series);
const defaults = ["Québec (province)", "Canada", "RMR de Montréal", "RMR de Québec"];
const measures = [["Composite_HPI", "Indice composite (2005 = 100)", "idx"], ["Composite_Benchmark", "Prix de référence composite ($)", "money"], ["YOY", "Variation annuelle de l’indice composite (%)", "pct"], ["REBASE", "Indice composite rebasé à 100 au début de la période", "idx"], ["Single_Family_HPI", "Indice — unifamiliales", "idx"], ["Apartment_HPI", "Indice — appartements", "idx"], ["Single_Family_Benchmark", "Prix de référence — unifamiliales ($)", "money"], ["Apartment_Benchmark", "Prix de référence — appartements ($)", "money"]];
const years = [...new Set(data.dates.map((d) => d.slice(0, 4)))];
host.innerHTML = ``;
const form = $("form", host);
const render = () => {
const m = form.elements.m.value;
const y0 = Number(form.elements.y0.value), y1 = Math.max(Number(form.elements.y1.value), y0);
form.elements.y0.parentElement.querySelector("output").textContent = years[y0];
form.elements.y1.parentElement.querySelector("output").textContent = years[y1];
const sel = $$("input[name=s]:checked", form).map((x) => x.value);
const i0 = data.dates.findIndex((d) => d.startsWith(years[y0]));
let i1 = data.dates.length - 1; for (let i = data.dates.length - 1; i >= 0; i--) if (data.dates[i].startsWith(years[y1])) { i1 = i; break; }
const xs = data.dates.slice(i0, i1 + 1);
const fmt = measures.find((x) => x[0] === m)[2];
const yfmt = fmt === "money" ? (v) => fmtNum(v / 1000, 0) + " k$" : fmt === "pct" ? (v) => fmtPct(v, 1) : (v) => fmtNum(v, 0);
const series = sel.map((n) => {
const s = data.series[n];
let arr;
if (m === "YOY") arr = s.Composite_HPI.map((v, i) => (i >= 12 && v !== null && s.Composite_HPI[i - 12]) ? (v / s.Composite_HPI[i - 12] - 1) * 100 : null);
else if (m === "REBASE") { const base = s.Composite_HPI[i0]; arr = s.Composite_HPI.map((v) => (v !== null && base ? v / base * 100 : null)); }
else arr = s[m] || [];
return { label: n, color: PALETTE[names.indexOf(n) % PALETTE.length], points: xs.map((_, k) => [k, arr[i0 + k] ?? null]), raw: arr };
});
const chartHost = $("#hpi-chart", host); chartHost.innerHTML = "";
if (!series.length) { chartHost.innerHTML = 'Sélectionnez au moins un marché.
'; return; }
seriesChart(chartHost, series, { xLabels: xs, yfmt, zeroLine: m === "YOY" });
// indicateurs
const rows = series.map((s) => {
const vals = s.points.map((p) => p[1]).filter((v) => v !== null && isFinite(v));
const first = vals[0], last = vals[vals.length - 1];
const yrs = vals.length / 12;
const cagr = first > 0 && yrs > 0 ? (Math.pow(last / first, 1 / yrs) - 1) * 100 : null;
let peak = -Infinity, dd = 0; vals.forEach((v) => { peak = Math.max(peak, v); dd = Math.min(dd, (v / peak - 1) * 100); });
const yoy = vals.length > 12 ? (last / vals[vals.length - 13] - 1) * 100 : null;
return `| ■ ${s.label} | ${yfmt(first)} | ${yfmt(last)} | ${fmt === "pct" ? "—" : fmtPct((last / first - 1) * 100, 1)} | ${cagr === null || fmt === "pct" ? "—" : fmtPct(cagr, 1)} | ${yoy === null || fmt === "pct" ? "—" : fmtPct(yoy, 1)} | ${fmt === "pct" ? "—" : fmtPct(dd, 1)} |
`;
});
$("#hpi-kpis", host).innerHTML = `| Marché | Début | Fin | Variation totale | Croissance annuelle | 12 derniers mois | Recul max. depuis un sommet |
${rows.join("")}
`;
};
form.addEventListener("input", render);
render();
}
async function initMacro() {
const host = $("#macro-explorer");
if (!host) return;
const data = await fetchJSON(host.dataset.src);
const ids = Object.keys(data);
host.innerHTML = `Source : FRED, Federal Reserve Bank of St. Louis (séries OCDE et Statistique Canada). Le taux de change quotidien est moyenné par mois.
`;
const form = $("form", host);
if (ids.includes("IRLTLT01CAM156N")) form.elements.b.value = "IRLTLT01CAM156N";
const render = () => {
const a = data[form.elements.a.value], b = data[form.elements.b.value];
const since = form.elements.y.value;
const pa = a.points.filter((p) => p[0] >= since);
const xs = pa.map((p) => p[0]);
const unitFmt = (u) => u === "%" ? (v) => fmtPct(v, 2) : u.startsWith("CAD") ? (v) => fmtNum(v, 3) : u === "habitants" ? (v) => fmtNum(v / 1e6, 2) + " M" : (v) => fmtNum(v, 0);
const series = [{ label: `${a.label} (${a.unit})`, color: PALETTE[0], points: pa.map((p, i) => [i, p[1]]) }];
if (b && b.unit === a.unit) { const map = new Map(b.points); series.push({ label: `${b.label} (${b.unit})`, color: PALETTE[1], points: xs.map((d, i) => [i, map.has(d) ? map.get(d) : null]) }); }
const chartHost = $("#macro-chart", host); chartHost.innerHTML = "";
seriesChart(chartHost, series, { xLabels: xs, yfmt: unitFmt(a.unit), zeroLine: a.unit === "%" });
const vals = pa.map((p) => p[1]);
const f = unitFmt(a.unit);
const kp = [{ label: `Dernière valeur (${xs[xs.length - 1]})`, value: f(vals[vals.length - 1]), cls: "main" }, { label: "Minimum de la période", value: f(Math.min(...vals)) }, { label: "Maximum de la période", value: f(Math.max(...vals)) }, { label: "Variation sur la période", value: a.unit === "%" ? `${fmtNum(vals[vals.length - 1] - vals[0], 2)} pt` : fmtPct((vals[vals.length - 1] / vals[0] - 1) * 100, 1) }];
if (b && b.unit !== a.unit) kp.push({ label: "Comparaison", value: "unités différentes : choisissez deux séries de même unité", cls: "warn" });
$("#macro-kpis", host).innerHTML = kp.map((k) => `${k.label}${k.value}
`).join("");
};
form.addEventListener("input", render);
render();
}
function initLightbox() {
$$(".figimg img").forEach((img) => img.addEventListener("click", () => {
const lb = document.createElement("div"); lb.className = "lightbox"; lb.innerHTML = `
`;
lb.addEventListener("click", () => lb.remove()); body.appendChild(lb);
}));
}
// ---------------------------------------------------------------------------
// Séances vidéo : lecteur personnalisé, chapitres, transcription synchronisée, storyboard
// ---------------------------------------------------------------------------
const hms = (t) => { t = Math.max(0, Math.floor(t || 0)); const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`; };
const parseT = (v) => { if (!v) return NaN; if (/^\d+(\.\d+)?$/.test(v)) return Number(v); const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(v); if (m && (m[1] || m[2] || m[3])) return (+m[1] || 0) * 3600 + (+m[2] || 0) * 60 + (+m[3] || 0); const p = v.split(":").map(Number); return p.some(isNaN) ? NaN : p.reduce((a, b) => a * 60 + b, 0); };
const VI = {
play: '',
pause: '',
back: '',
fwd: '',
vol: '',
mute: '',
cc: '',
pip: '',
theater: '',
theaterOff: '',
full: '',
fullOff: '',
skip: '',
};
function initVideosIndex() {
$$("[data-video-progress]").forEach((el) => {
const st = store.get("video:" + el.dataset.videoProgress, null);
const bar = $(".bar i", el), lab = $("span", el);
if (!st || !st.dur) return;
const pct = Math.round(Math.min(100, (st.max || st.t) / st.dur * 100));
if (pct < 1 && !st.done) return;
bar.style.width = pct + "%";
if (st.done || pct >= 90) { el.classList.add("done"); lab.textContent = "Séance visionnée"; }
else lab.textContent = `Vu à ${pct} % · reprendre à ${hms(st.t)}`;
});
}
async function initVideo() {
const main = $(".video-page");
if (!main) return;
const nn = main.dataset.video, video = $("#vid", main), player = $("#vid-player", main);
const key = "video:" + nn;
const startT = Number(main.dataset.start || 0);
const p = progress(); p.last = { ch: nn, title: main.dataset.title, url: location.pathname }; saveProgress(p);
let data = null;
try { data = await fetchJSON(main.dataset.src); } catch (e) { $("#tr-list").innerHTML = 'Transcription indisponible.
'; }
const chaps = (data && data.chapters) || [];
const blocks = (data && data.blocks) || [];
const sb = (data && data.storyboard) || [];
const dur = () => (isFinite(video.duration) && video.duration) || (data && data.duration) || 0;
const clamp = (t) => Math.max(0, Math.min(t, dur() ? dur() - .3 : t));
const seek = (t, play = true) => { try { video.currentTime = clamp(t); } catch (e) { /* not ready */ } if (play) video.play().catch(() => {}); };
const chapAt = (t) => { let ci = -1; for (let i = 0; i < chaps.length; i++) if (chaps[i].t <= t) ci = i; else break; return ci; };
const frameAt = (t) => { let bi = -1; for (let i = 0; i < sb.length; i++) if (sb[i].t <= t + .5) bi = i; else break; return sb[bi] || null; };
// ---- interface du lecteur (remplace les contrôles natifs)
video.removeAttribute("controls");
const ui = document.createElement("div"); ui.className = "vp-ui";
const total = dur() || (chaps.length ? chaps[chaps.length - 1].end : 1);
const segs = chaps.length ? chaps : [{ t: 0, end: total, title: "" }];
const li = (c) => $(`.vid-chaps .vc[data-t="${c.t}"]`, main);
ui.innerHTML = `
${startT ? `` : ""}
${segs.map((c) => ``).join("")}
0:00 / ${hms(total)}
${document.pictureInPictureEnabled ? `` : ""}
`;
player.appendChild(ui);
const q = (s) => $(s, ui);
const playBtns = [q(".vp-big"), q(".vp-btn.play")];
const toggle = () => { video.paused ? video.play().catch(() => {}) : video.pause(); };
const flash = (svg) => { const f = q(".vp-flash"); f.innerHTML = svg; f.classList.remove("go"); void f.offsetWidth; f.classList.add("go"); };
let toastTimer; const vtoast = (msg) => { const t = q("#vp-toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 1400); };
playBtns.forEach((b) => b.addEventListener("click", toggle));
let tapWasIdle = false;
q(".vp-click").addEventListener("pointerdown", () => { tapWasIdle = player.classList.contains("vp-idle"); });
q(".vp-click").addEventListener("click", () => { if (tapWasIdle && matchMedia("(pointer: coarse)").matches) return; toggle(); flash(video.paused ? VI.play : VI.pause); });
q(".vp-click").addEventListener("dblclick", () => fullscreen());
q(".vp-btn.back").addEventListener("click", () => { seek(video.currentTime - 10, !video.paused); vtoast("−10 s"); });
q(".vp-btn.fwd").addEventListener("click", () => { seek(video.currentTime + 10, !video.paused); vtoast("+10 s"); });
const setState = () => { player.dataset.state = video.paused ? (video.currentTime > 0 || player.dataset.state === "paused" ? "paused" : "idle") : "playing"; const ic = video.paused ? VI.play : VI.pause; q(".vp-btn.play").innerHTML = ic; q(".vp-btn.play").setAttribute("aria-label", video.paused ? "Lecture" : "Pause"); };
video.addEventListener("play", () => { player.dataset.state = "playing"; setState(); });
video.addEventListener("pause", () => { player.dataset.state = "paused"; setState(); });
// masquage automatique
let idleTimer, overControls = false;
const wake = () => { player.classList.remove("vp-idle"); clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (!video.paused && !q(".vp-menu.open") && !overControls) player.classList.add("vp-idle"); else wake(); }, 2600); };
q(".vp-controls").addEventListener("mouseenter", () => { overControls = true; }); q(".vp-controls").addEventListener("mouseleave", () => { overControls = false; });
["mousemove", "touchstart", "keydown", "click"].forEach((ev) => player.addEventListener(ev, wake, { passive: true }));
player.addEventListener("mouseleave", () => { if (!video.paused) player.classList.add("vp-idle"); });
video.addEventListener("play", wake);
// barre de progression
const seekEl = q(".vp-seek"), track = q(".vp-track"), knob = q(".vp-knob"), preview = q(".vp-preview");
const segEls = $$(".vp-seg", track);
const tFromX = (x) => { const r = track.getBoundingClientRect(); return Math.max(0, Math.min(1, (x - r.left) / r.width)) * (dur() || total); };
const showPreview = (x) => {
const t = tFromX(x); const r = track.getBoundingClientRect(); const px = Math.max(0, Math.min(1, (x - r.left) / r.width));
const f = frameAt(t); const ci = chapAt(t);
preview.hidden = false; const img = $("img", preview); if (f && img.dataset.src !== f.src) { img.src = f.src; img.dataset.src = f.src; } img.style.visibility = f ? "visible" : "hidden";
$("b", preview).textContent = hms(t); $("span", preview).textContent = ci >= 0 ? chaps[ci].title : "";
const w = preview.offsetWidth || 220, half = w / 2 / r.width; const left = Math.max(half, Math.min(1 - half, px)) * 100;
preview.style.left = `${left}%`; knob.style.left = `${px * 100}%`; knob.style.transform = "translate(-50%, -50%) scale(1)";
};
let dragging = false, wasPlaying = false;
seekEl.addEventListener("pointermove", (e) => { if (!dragging) showPreview(e.clientX); else { showPreview(e.clientX); video.currentTime = clamp(tFromX(e.clientX)); } });
seekEl.addEventListener("pointerleave", () => { if (!dragging) { preview.hidden = true; knob.style.transform = ""; } });
seekEl.addEventListener("pointerdown", (e) => { dragging = true; wasPlaying = !video.paused; video.pause(); seekEl.classList.add("drag"); seekEl.setPointerCapture(e.pointerId); showPreview(e.clientX); video.currentTime = clamp(tFromX(e.clientX)); });
const endDrag = (e) => { if (!dragging) return; dragging = false; seekEl.classList.remove("drag"); preview.hidden = true; knob.style.transform = ""; video.currentTime = clamp(tFromX(e.clientX)); if (wasPlaying) video.play().catch(() => {}); };
seekEl.addEventListener("pointerup", endDrag); seekEl.addEventListener("pointercancel", endDrag);
seekEl.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft") { e.preventDefault(); seek(video.currentTime - 5, !video.paused); } if (e.key === "ArrowRight") { e.preventDefault(); seek(video.currentTime + 5, !video.paused); } });
// volume
const volIn = q(".vp-vol input"), muteBtn = q(".vp-btn.mute");
const syncVol = () => { muteBtn.innerHTML = video.muted || video.volume === 0 ? VI.mute : VI.vol; volIn.value = video.muted ? 0 : video.volume; };
muteBtn.addEventListener("click", () => { video.muted = !video.muted; store.setGlobal("video-muted", video.muted); syncVol(); });
volIn.addEventListener("input", () => { video.volume = Number(volIn.value); video.muted = video.volume === 0; store.setGlobal("video-volume", video.volume); syncVol(); });
video.volume = store.getGlobal("video-volume", 1); video.muted = store.getGlobal("video-muted", false); syncVol();
// vitesse
const rateBtn = q(".vp-btn.rate"), rateMenu = q(".rate-menu");
const setRate = (r) => { video.playbackRate = r; rateBtn.textContent = `${String(r).replace(".", ",")}×`; $$("button", rateMenu).forEach((b) => b.setAttribute("aria-checked", String(Number(b.dataset.rate) === r))); store.setGlobal("video-rate", r); };
rateBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = rateMenu.classList.toggle("open"); rateBtn.setAttribute("aria-expanded", String(open)); });
$$("button", rateMenu).forEach((b) => b.addEventListener("click", () => { setRate(Number(b.dataset.rate)); rateMenu.classList.remove("open"); rateBtn.setAttribute("aria-expanded", "false"); vtoast(`Vitesse ${b.textContent.trim()}`); }));
document.addEventListener("click", (e) => { if (!e.target.closest(".vp-menu-wrap")) { rateMenu.classList.remove("open"); rateBtn.setAttribute("aria-expanded", "false"); } });
setRate(store.getGlobal("video-rate", 1));
// sous-titres
const ccBtn = q(".vp-btn.cc");
const subs = () => Array.from(video.textTracks || []).find((t) => t.kind === "subtitles");
const setCC = (on) => { const t = subs(); if (t) t.mode = on ? "showing" : "hidden"; ccBtn.setAttribute("aria-pressed", String(on)); store.setGlobal("video-cc", on); };
Array.from(video.textTracks || []).forEach((t) => { t.mode = "hidden"; });
ccBtn.addEventListener("click", () => { const on = ccBtn.getAttribute("aria-pressed") !== "true"; setCC(on); vtoast(on ? "Sous-titres activés" : "Sous-titres désactivés"); });
setCC(store.getGlobal("video-cc", false));
// image dans l'image, cinéma, plein écran
q(".vp-btn.pip")?.addEventListener("click", async () => { try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else await video.requestPictureInPicture(); } catch (e) { vtoast("Image dans l’image non disponible"); } });
const theaterBtn = q(".vp-btn.theater");
const setTheater = (on) => { main.classList.toggle("theater", on); theaterBtn.setAttribute("aria-pressed", String(on)); theaterBtn.innerHTML = on ? VI.theaterOff : VI.theater; store.setGlobal("video-theater", on); };
theaterBtn.addEventListener("click", () => setTheater(!main.classList.contains("theater")));
if (innerWidth > 1100) setTheater(store.getGlobal("video-theater", false));
const fullBtn = q(".vp-btn.full");
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen(); else if (player.requestFullscreen) player.requestFullscreen().catch(() => {}); else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); };
fullBtn.addEventListener("click", fullscreen);
document.addEventListener("fullscreenchange", () => { fullBtn.innerHTML = document.fullscreenElement ? VI.fullOff : VI.full; });
// passer l'accueil
const skipBtn = q(".vp-skip");
skipBtn?.addEventListener("click", () => { seek(startT); vtoast("Début du cours"); });
// ---- onglets du panneau
const tabs = $$(".vid-tabs button", main), panes = $$(".vid-pane", main);
const showPane = (name) => { tabs.forEach((b) => b.setAttribute("aria-selected", String(b.dataset.pane === name))); panes.forEach((pn) => { pn.hidden = pn.dataset.pane !== name; }); store.setGlobal("video-pane", name); };
tabs.forEach((b) => b.addEventListener("click", () => showPane(b.dataset.pane)));
const savedPane = store.getGlobal("video-pane", "chapters"); if (tabs.some((b) => b.dataset.pane === savedPane)) showPane(savedPane);
// ---- chapitres (liste)
const chapEls = $$(".vid-chaps .vc", main);
chapEls.forEach((el) => { $(".vc-btn", el).addEventListener("click", () => { seek(Number(el.dataset.t)); if (innerWidth <= 1100) player.scrollIntoView({ behavior: "smooth", block: "start" }); }); });
// ---- transcription
const list = $("#tr-list", main), follow = $("#tr-follow", main), search = $("#tr-search", main), count = $("#tr-count", main);
let trEls = [];
if (list && blocks.length) {
const frag = document.createDocumentFragment();
let ci = -1;
blocks.forEach((b, i) => {
while (ci + 1 < chaps.length && chaps[ci + 1].t <= b[0]) { ci++; const h = document.createElement("div"); h.className = "tr-chap"; h.textContent = chaps[ci].title; frag.appendChild(h); }
const pEl = document.createElement("p"); pEl.className = "tr-seg"; pEl.dataset.t = b[0]; pEl.dataset.e = b[1]; pEl.dataset.i = i; pEl.innerHTML = `${hms(b[0])}${esc(b[2])}`;
pEl.addEventListener("click", () => seek(b[0]));
frag.appendChild(pEl);
});
list.innerHTML = ""; list.appendChild(frag);
trEls = $$(".tr-seg", list);
let userScrolled = false, scrollTimer;
const mark = () => { userScrolled = true; clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { userScrolled = false; }, 4000); };
list.parentElement.addEventListener("wheel", mark, { passive: true }); list.parentElement.addEventListener("touchmove", mark, { passive: true });
main._trFollow = () => follow.checked && !userScrolled;
const acc = (w) => w.replace(/e/g, "[eéèêë]").replace(/a/g, "[aàâ]").replace(/i/g, "[iîï]").replace(/o/g, "[oôœ]").replace(/u/g, "[uûü]").replace(/c/g, "[cç]");
const runSearch = debounce(() => {
const qv = norm(search.value.trim());
if (!qv) { trEls.forEach((el) => { el.classList.remove("hide"); $(".tr-text", el).innerHTML = esc(blocks[el.dataset.i][2]); }); $$(".tr-chap", list).forEach((h) => { h.hidden = false; }); count.textContent = ""; return; }
const words = qv.split(/\s+/).filter((w) => w.length > 1).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const re = new RegExp("(" + words.map(acc).join("|") + ")", "gi");
let n = 0;
trEls.forEach((el) => { const txt = blocks[el.dataset.i][2]; const hit = words.every((w) => new RegExp(acc(w), "i").test(txt)); el.classList.toggle("hide", !hit); if (hit) { n++; $(".tr-text", el).innerHTML = esc(txt).replace(re, "$1"); } });
$$(".tr-chap", list).forEach((h) => { h.hidden = true; });
count.textContent = n ? `${n} passage${n > 1 ? "s" : ""} — cliquez pour y aller` : "Aucun passage ne contient ces mots.";
}, 120);
search?.addEventListener("input", runSearch);
}
// ---- storyboard
const sbHost = $("#vid-sb", main);
let sbEls = [];
if (sbHost && sb.length) {
sbHost.innerHTML = sb.map((f, i) => `
${hms(f.t)}`).join("");
sbEls = $$("figure", sbHost);
sbEls.forEach((f) => f.addEventListener("click", () => seek(Number(f.dataset.t))));
} else if (sbHost) sbHost.closest(".vid-sb-wrap").hidden = true;
// ---- questions / déroulé
$$(".vq-t[data-t], .vt-seek[data-t]", main).forEach((b) => b.addEventListener("click", () => { seek(Number(b.dataset.t)); player.scrollIntoView({ behavior: "smooth", block: "center" }); }));
// ---- barre secondaire
const audioBtn = $("[data-vid='audio']", main);
audioBtn?.addEventListener("click", () => {
const on = audioBtn.getAttribute("aria-pressed") !== "true";
const t = video.currentTime, playing = !video.paused;
video.src = on ? video.dataset.audioSrc : video.dataset.videoSrc;
video.classList.toggle("audio-only", on); player.classList.toggle("audio-mode", on);
audioBtn.setAttribute("aria-pressed", String(on));
video.addEventListener("loadedmetadata", () => { video.currentTime = t; if (playing) video.play().catch(() => {}); }, { once: true });
video.load();
toast(on ? "Audio seulement : la vidéo n’est plus téléchargée." : "Retour à la vidéo.");
});
$("[data-vid='link']", main)?.addEventListener("click", async () => {
const url = `${location.origin}${location.pathname}?t=${Math.floor(video.currentTime)}`;
try { await navigator.clipboard.writeText(url); toast(`Lien copié : ${hms(video.currentTime)}`); } catch (e) { prompt("Copiez ce lien :", url); }
});
const resume = $("#vid-resume", main);
$("[data-vid='resume']", main)?.addEventListener("click", () => { const st = store.get(key, null); resume.hidden = true; if (st) seek(st.t); });
$("[data-vid='restart']", main)?.addEventListener("click", () => { resume.hidden = true; seek(startT || 0); });
// ---- position de départ : ?t= / #t= / reprise
const qs = new URLSearchParams(location.search);
const wanted = parseT(qs.get("t") || (location.hash.startsWith("#t=") ? location.hash.slice(3) : ""));
const saved = store.get(key, null);
const onMeta = () => {
$(".vp-time span", ui).textContent = `/ ${hms(dur())}`; seekEl.setAttribute("aria-valuemax", String(Math.round(dur())));
if (!isNaN(wanted)) { seek(wanted, false); player.dataset.state = "paused"; vtoast(`Position : ${hms(wanted)}`); }
else if (saved && saved.t > 45 && saved.t < dur() - 60 && !saved.done) { $("#vid-resume-t").textContent = hms(saved.t); resume.hidden = false; }
update();
};
if (video.readyState >= 1) onMeta(); else video.addEventListener("loadedmetadata", onMeta, { once: true });
video.addEventListener("play", () => { resume.hidden = true; });
video.addEventListener("progress", () => { const d = dur(); if (!d || !video.buffered.length) return; const end = video.buffered.end(video.buffered.length - 1); segEls.forEach((s) => { const a = Number(s.dataset.t), b = Number(s.dataset.end); $(".buf", s).style.width = `${Math.max(0, Math.min(1, (end - a) / (b - a))) * 100}%`; }); });
// ---- synchronisation
let curChap = -1, curSeg = -1, curSb = -1, lastSave = 0;
const chapNow = $("#vid-chapnow", main), badge = q("#vp-chap"), title = q("#vp-title");
const update = () => {
const t = video.currentTime, d = dur();
$(".vp-time .cur", ui).textContent = hms(t); seekEl.setAttribute("aria-valuenow", String(Math.round(t))); seekEl.setAttribute("aria-valuetext", hms(t));
if (!dragging) knob.style.left = `${d ? t / d * 100 : 0}%`;
segEls.forEach((s) => { const a = Number(s.dataset.t), b = Number(s.dataset.end); $(".played", s).style.width = `${(t <= a ? 0 : t >= b ? 1 : (t - a) / (b - a)) * 100}%`; });
if (skipBtn) skipBtn.hidden = !(startT && t < startT - 5);
const ci = chapAt(t);
if (ci !== curChap) {
curChap = ci; const c = chaps[ci];
chapEls.forEach((el, i) => { el.classList.toggle("cur", i === ci); el.classList.toggle("seen", i < ci); });
const el = chapEls[ci]; if (el && !$(".vid-pane[data-pane='chapters']", main).hidden) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
if (c) { badge.hidden = false; badge.innerHTML = `${hms(c.t)}${esc(c.title)}`; title.textContent = c.title; if (chapNow) chapNow.innerHTML = `${hms(c.t)}${esc(c.title)}`; } else { badge.hidden = true; title.textContent = ""; }
}
if (trEls.length) {
let si = -1; for (let i = 0; i < blocks.length; i++) if (blocks[i][0] <= t + .3) si = i; else break;
if (si !== curSeg) { curSeg = si; trEls.forEach((el, i) => el.classList.toggle("cur", i === si)); const el = trEls[si]; if (el && main._trFollow() && !$(".vid-pane[data-pane='transcript']", main).hidden && !el.classList.contains("hide")) el.scrollIntoView({ block: "center", behavior: "smooth" }); }
}
if (sbEls.length) { let bi = -1; for (let i = 0; i < sb.length; i++) if (sb[i].t <= t + .5) bi = i; else break; if (bi !== curSb) { curSb = bi; sbEls.forEach((f, i) => f.classList.toggle("cur", i === bi)); const f = sbEls[bi]; if (f) f.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" }); } }
if (!video.paused && Date.now() - lastSave > 4000) { lastSave = Date.now(); save(); }
};
const save = () => { const t = video.currentTime, d = dur(); if (!d || (t < 5 && !store.get(key, null))) return; const st = store.get(key, { max: 0 }); st.t = t; st.dur = d; st.max = Math.max(st.max || 0, t); st.done = st.done || st.max / d >= .9; st.at = Date.now(); store.set(key, st); const done = $("[data-video-done]", main); if (done) done.hidden = !st.done; };
video.addEventListener("timeupdate", update);
video.addEventListener("seeked", update);
video.addEventListener("pause", save);
video.addEventListener("ended", () => { const st = store.get(key, {}); st.done = true; store.set(key, st); save(); player.dataset.state = "paused"; toast("Séance visionnée au complet. Bravo !", "ok"); confetti(); });
addEventListener("pagehide", save);
if (saved && saved.done) { const done = $("[data-video-done]", main); if (done) done.hidden = false; }
// ---- clavier
addEventListener("keydown", (e) => {
if (e.target.matches("input, textarea, select") || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === " " || e.key === "k") { e.preventDefault(); toggle(); flash(video.paused ? VI.play : VI.pause); }
else if (e.key === "ArrowLeft" || e.key === "j") { e.preventDefault(); seek(video.currentTime - 10, !video.paused); vtoast("−10 s"); }
else if (e.key === "ArrowRight" || e.key === "l") { e.preventDefault(); seek(video.currentTime + 10, !video.paused); vtoast("+10 s"); }
else if (e.key === "c") ccBtn.click();
else if (e.key === "f") fullscreen();
else if (e.key === "t") theaterBtn.click();
else if (e.key === "m") muteBtn.click();
else if (e.key === "<" || e.key === ">") { const rates = [.75, 1, 1.25, 1.5, 1.75, 2]; const i = rates.indexOf(video.playbackRate); setRate(rates[Math.max(0, Math.min(rates.length - 1, i + (e.key === ">" ? 1 : -1)))]); vtoast(`Vitesse ${rateBtn.textContent}`); }
else if (/^[0-9]$/.test(e.key)) { seek(dur() * Number(e.key) / 10, !video.paused); }
wake();
});
const kbd = document.createElement("p"); kbd.className = "vid-kbd"; kbd.innerHTML = "espace lecture/pause · ← → ±10 s · c sous-titres · < > vitesse · t mode cinéma · f plein écran · m muet · survolez la barre pour voir la diapositive"; $(".vid-bar", main).after(kbd);
}
// ---------------------------------------------------------------------------
// Démarrage
// ---------------------------------------------------------------------------
initTheme();
initNav();
initSearch();
initGlossaryTips();
initChapter();
if ($("#quiz-page")) Quiz.mount($("#quiz-page"), {});
initFlashcards();
initTools();
initTraining();
initGames();
initProgressPage();
initMap();
initGlossaryPage();
initSlides();
initSideSpy();
initHashDetails();
renderProgressWidgets();
Widgets.mountAll();
initHPI();
initMacro();
initLightbox();
initVideosIndex();
initVideo();
})();