"""templates.py — gabarits HTML v2 (thème clair UQO, menus, pages interactives, rapport marché).""" from __future__ import annotations import html import json import math import re ICONS = """ """ ICON_MAP = {"book": "i-book", "formula": "i-formula", "bulb": "i-bulb", "pencil": "i-pencil", "warning": "i-warning", "hardhat": "i-hardhat", "pen": "i-pen", "map": "i-map", "star": "i-star", "target": "i-target", "clipboard": "i-clipboard", "check": "i-check"} def icon(name: str, cls: str = "") -> str: return f'' def inject_box_icons(h: str) -> str: return re.sub(r'', lambda m: f'{icon(ICON_MAP.get(m.group(1), "i-pen"))}', h) def e(s) -> str: return html.escape(str(s), quote=True) def strip_tags(h: str) -> str: return html.unescape(re.sub(r"<[^>]+>", "", h or "")) def part_of(course: dict, n: int) -> tuple[int, str, str, str]: """(index 1-based, romain, titre, couleur)""" for i, (roman, title, nums) in enumerate(course["parts"]): if n in nums: return i + 1, roman, title, course["part_colors"][i] return 1, "", "", course["part_colors"][0] # ----------------------------------------------------------------------------- # Gabarit de page # ----------------------------------------------------------------------------- def nav_html(course: dict, chapters: list, active: str, has_report: bool) -> str: parts = [] for i, (roman, ptitle, nums) in enumerate(course["parts"]): col = course["part_colors"][i] parts.append(f'
Partie {roman} — {e(ptitle)}
') for n in nums: parts.append(f'{n}{chapters[n - 1].title_html}') if course.get("has_videos"): parts.append('
Enregistrements
') parts.append('▶Séances vidéo — enregistrements Zoom, transcription synchronisée') parts.append('
Annexes
') parts.append('AAide-mémoire des formules') parts.append('BGlossaire') if has_report: parts.append('RRapport — Le marché de l’habitation au Québec') def item(href, ico, label, desc): return f'{icon(ico)}{label}{desc}' reviser = [item("/aide-memoire/", "i-formula", "Aide-mémoire", "Toutes les formules commentées"), item("/glossaire/", "i-book", "Glossaire", "Termes du métier, équivalents anglais"), item("/definitions/", "i-list", "Toutes les définitions", "Les formulations attendues aux examens"), item("/fiches/", "i-cards", "Fiches de révision", "Recto-verso, suivi d’acquisition"), item("/carte/", "i-network", "Carte du cours", "Les 14 séances et leurs objectifs en un coup d’œil"), item("/exercices/", "i-pencil", "Banque d’exercices", "Tous les exercices corrigés")] exercer = [item("/quiz/", "i-quiz", "Quiz et examens blancs", "QCM avec rétroaction, chronomètre"), item("/entrainement/", "i-dumbbell", "Entraînement", "Problèmes à données aléatoires, corrigés pas à pas"), item("/jeux/", "i-game", "Jeux de révision", "Association, vrai ou faux, remise en ordre"), item("/outils/", "i-calc", "Calculateurs", "Les formules en interactif"), item("/progression/", "i-progress", "Ma progression", "Lecture, quiz, objectifs, notes")] ressources = [item("/diapositives/", "i-slides", "Diapositives", "Les 14 présentations en ligne"), item("/ressources/", "i-download", "Téléchargements", "Manuel, plan, TP, gabarits Excel")] if course.get("has_videos"): ressources.insert(1, item("/videos/", "i-video", "Séances vidéo", "Enregistrements Zoom : chapitres, transcription, sous-titres")) if has_report: ressources.append(item("/rapport/", "i-report", "Rapport marché QC", "Indices de prix, macro, abordabilité — 26 sections, 67 figures")) ressources += [item("/bibliographie/", "i-bib", "Bibliographie", "Références citées"), item("/recherche/", "i-search", "Recherche", "Tout le manuel indexé")] groups = [("seances", "Séances", parts, "wide"), ("reviser", "Réviser", reviser, ""), ("exercer", "S’exercer", exercer, ""), ("ressources", "Ressources", ressources, "")] out = [] for key, label, items, cls in groups: cur = " current" if key == active else "" out.append(f'
{"".join(items)}
') if course.get("has_videos"): cur = ' aria-current="page"' if active == "videos" else "" out.insert(1, f'{icon("i-video")} Vidéos') return "".join(out) def layout(course: dict, chapters: list, *, title: str, body: str, active: str = "", desc: str = "", body_class: str = "", extra_head: str = "", extra_body: str = "", readbar: bool = False, canonical: str = "/", has_report: bool = True, body_attrs: str = "") -> str: code = course["code"] full_title = f"{title} · {code} — {course['title']}" if title else f"{code} — {course['title']} · Notes de cours interactives" desc = desc or course["subtitle"] return f""" {e(full_title)} {extra_head} {ICONS}
{code}{e(course['title'])}
{'
' if readbar else ''}
{body} {extra_body} """ def page_head(eyebrow: str, title: str, lead: str = "") -> str: lead_html = '

' + lead + "

" if lead else "" return f'
{e(eyebrow)}

{title}

{lead_html}
' HERO_DECO = """""" def kpi(label: str, value: str, small: str = "", cls: str = "", color: str = "") -> str: return f'
{label}{value}{f"{small}" if small else ""}
' # ----------------------------------------------------------------------------- # Carte du cours (SVG) # ----------------------------------------------------------------------------- def course_map_svg(course: dict, chapters: list, compact: bool = False) -> str: n = len(chapters) cx, cy, R = 400, 400, 300 out = [f''] # arcs de partie for i, (roman, ptitle, nums) in enumerate(course["parts"]): a0 = (nums[0] - 1) / n * 2 * math.pi - math.pi / 2 - math.pi / n * .9 a1 = (nums[-1] - 1) / n * 2 * math.pi - math.pi / 2 + math.pi / n * .9 x0, y0 = cx + R * math.cos(a0), cy + R * math.sin(a0) x1, y1 = cx + R * math.cos(a1), cy + R * math.sin(a1) large = 1 if a1 - a0 > math.pi else 0 col = course["part_colors"][i] out.append(f'') am = (a0 + a1) / 2 lx, ly = cx + (R + 88) * math.cos(am), cy + (R + 88) * math.sin(am) anchor = "middle" if abs(math.cos(am)) < .3 else ("start" if math.cos(am) > 0 else "end") out.append(f'Partie {roman}') # centre out.append(f'{course["code"]}{e(course["short"])}14 séances') # nœuds for i, ch in enumerate(chapters, 1): a = (i - 1) / n * 2 * math.pi - math.pi / 2 x, y = cx + R * math.cos(a), cy + R * math.sin(a) _, _, _, col = part_of(course, i) title = strip_tags(ch.title_html) # libellé raccourci sur 2 lignes words = title.replace(" — ", " : ").split() l1, l2 = "", "" for w in words: if len(l1) + len(w) < 20 and not l2: l1 = (l1 + " " + w).strip() elif len(l2) + len(w) < 20: l2 = (l2 + " " + w).strip() else: l2 = (l2 + "…") if not l2.endswith("…") else l2 break lx, ly = cx + (R + 44) * math.cos(a), cy + (R + 44) * math.sin(a) anchor = "middle" if abs(math.cos(a)) < .25 else ("start" if math.cos(a) > 0 else "end") dy = 0 if abs(math.sin(a)) < .9 else (14 if math.sin(a) > 0 else -22) out.append(f'{i}' f'{e(l1)}{f"{e(l2)}" if l2 else ""}') out.append("") return "".join(out) # ----------------------------------------------------------------------------- # Accueil # ----------------------------------------------------------------------------- def home_page(course: dict, chapters: list, stats: dict, files: dict, report_kpis: str, has_report: bool) -> str: code = course["code"] cards = [] for i, (roman, ptitle, nums) in enumerate(course["parts"]): col = course["part_colors"][i] cards.append(f'
Partie {roman}{e(ptitle)}
') for n in nums: ch = chapters[n - 1] c = ch.counts ev = "".join(f'{e(t)}' for k, t in course["events"].get(n, [])) cards.append(f"""
Séance {n:02d}

{ch.title_html}

{f'
{ev}
' if ev else ''}
{icon('i-book')} {c['def']} déf.{icon('i-formula')} {c['form']} form.{icon('i-bulb')} {c['ex']} ex.{icon('i-pencil')} {c['exo']} exo.
""") cards.append("
") # ligne du temps tl = [] for n in range(1, 15): _, _, _, col = part_of(course, n) evs = course["events"].get(n, []) ev_html = "".join(f'{e(t)}' for k, t in evs) tl.append(f'{n}{ev_html}') eval_rows = "".join(f"{e(a)}{e(b)}{e(c)}{e(d)}" for a, b, c, d in course["evaluation"]) features = [ ("/seance/01/", "i-book", "Notes de cours intégrales", "Les 14 séances du manuel : définitions et formules numérotées, exemples chiffrés, pièges, contexte québécois, exercices corrigés, fiche synthèse.", False), ("/seance/03/", "i-chart", "Explorateurs interactifs", "Des graphiques à curseurs insérés dans les séances : offre et demande, cycle, sensibilité au TGA, DCF, ventilation de la dépréciation…", True), ("/entrainement/", "i-dumbbell", "Entraînement infini", "Problèmes à données aléatoires corrigés pas à pas, avec suivi de vos séries.", True), ("/quiz/", "i-quiz", "Quiz et examens blancs", f"{stats['quiz']} questions avec rétroaction, un examen blanc chronométré, vos meilleurs scores.", False), ("/jeux/", "i-game", "Jeux de révision", "Associez termes et définitions, tranchez vrai ou faux, remettez les étapes dans l’ordre.", True), ("/fiches/", "i-cards", "Fiches de révision", f"{stats['cards']} fiches recto-verso générées des définitions, du glossaire et des formules.", False), ("/outils/", "i-calc", "Calculateurs", "Les formules du cours en calculateurs : modifiez les hypothèses et voyez la valeur réagir.", False), ("/seance/01/#notes", "i-note", "Surlignage et notes", "Surlignez les passages, annotez-les, exportez vos notes en Markdown ; écoutez la séance en audio.", True), ("/progression/", "i-progress", "Tableau de bord", "Lecture, quiz, objectifs, fiches, entraînement : votre progression séance par séance.", True), ] if course.get("has_videos"): features.insert(1, ("/videos/", "i-video", "Séances vidéo", "Les enregistrements Zoom des cours : chapitres, transcription synchronisée cliquable, sous-titres, diapositives au fil de la vidéo.", True)) feat_html = "".join(f'
{icon(i)}

{t}{"Nouveau" if new else ""}

{d}

' for u, i, t, d, new in features) report_html = f"""
Ressource transversale

Le marché de l’habitation au Québec

Rapport de référence de 106 pages : indices de prix MLS® 2005–2026, six marchés québécois, cadre macroéconomique et abordabilité. Intégralement consultable, avec un explorateur interactif des données.

{icon('i-report')} Ouvrir le rapport
{report_kpis}
""" if has_report else "" body = f"""
{HERO_DECO}
UQOUniversité du Québec en Outaouais · Sciences administratives
Notes de cours interactives · {e(course['term'])}

{code}{e(course['title'])}

{e(course['subtitle'])}

14séances
{stats['def']}définitions
{stats['form']}formules
{stats['ex']}exemples travaillés
{stats['exo']}exercices corrigés
{stats['fig']}figures et tableaux
{stats['quiz']}questions de quiz
{format(stats['words'], ',').replace(',', ' ')}mots

Votre progression

Aucune séance commencée
0/14 séances terminées · 0 %
Les quiz réussis à 70 % marquent la séance comme terminée.
Commencer
Parcours de la session

Quatorze séances, trois travaux, deux examens

Survolez une séance ; les annonces et remises de TP et les examens sont indiqués.

{''.join(tl)}
Programme

Les 14 séances

Un chapitre par séance, dans l’ordre du plan de cours. Les anneaux indiquent votre avancement ; chaque partie a sa couleur.

{f'{icon("i-video")} Séances vidéo' if course.get("has_videos") else ''}{icon('i-slides')} Diapositives
{''.join(cards)}
Étudier autrement

Tout le manuel, en interactif

{feat_html}
Évaluation

Pondération et calendrier

Travaux et examens à la maison ; IA tolérée avec déclaration obligatoire ; personnalisation par code permanent.

{eval_rows}
ComposanteMatière et livrablePondérationÉchéance
Total100 %
{report_html}
Téléchargements

Documents du cours

Tous les fichiers
""" return layout(course, chapters, title="", body=body, active="", canonical="/", has_report=has_report) # ----------------------------------------------------------------------------- # Page de séance # ----------------------------------------------------------------------------- def chapter_page(course: dict, chapters: list, ch, n: int, body_html: str, meta: dict, slide_url: str | None, has_report: bool, video_url: str | None = None) -> str: prev_ch = chapters[n - 2] if n > 1 else None next_ch = chapters[n] if n < 14 else None pidx, part_roman, part_title, col = part_of(course, n) toc_items = [] steps = [] for level, num, title_html, sid in ch.toc: if level > 3: continue toc_items.append(f'
  • {num}{title_html}
  • ') if level == 2: steps.append(f'{num.split(".")[-1]}{strip_tags(title_html)}') events = course["events"].get(n, []) ev_html = "".join(f'{e(t)}' for _, t in events) c = ch.counts hero = f"""
    Partie {part_roman} {e(part_title)} · Séance {n} · {course['code']}

    {ch.title_html}

    {e(course['title'])} · {e(course['term'])} · Simon-Pierre Boucher · UQO
    {icon('i-list')} {len([t for t in ch.toc if t[0] == 2])} sections {icon('i-book')} {c['def']} définition{'s' if c['def'] > 1 else ''} {icon('i-formula')} {c['form']} formule{'s' if c['form'] > 1 else ''} {icon('i-bulb')} {c['ex']} exemple{'s' if c['ex'] > 1 else ''} {icon('i-pencil')} {c['exo']} exercice{'s' if c['exo'] > 1 else ''} {icon('i-eye')} ≈ {meta['minutes']} min de lecture {f'{icon("i-chart")} {meta["widgets"]} explorateur{"s" if meta["widgets"] > 1 else ""} interactif{"s" if meta["widgets"] > 1 else ""}' if meta.get("widgets") else ''} {ev_html}
    {f'{icon("i-video")} Enregistrement de la séance' if video_url else ''} {f'{icon("i-slides")} Diapositives (PDF)' if slide_url else ''} {icon('i-quiz')} Quiz de la séance
    """ toc = f""" """ pager = f""" """ tools = f""" """ body = f"""
    {hero}
    {toc}
    {icon('i-eye')}Mode révision — seuls les définitions, formules, mises en garde, « À retenir », notes marginales et la fiche synthèse sont affichés.
    {body_html}

    Chargement du quiz…

    {pager} {tools}
    """ desc = f"Séance {n} du cours {course['code']} ({course['title']}) : {ch.title_text}. Notes de cours complètes, explorateurs interactifs, exercices corrigés et quiz." return layout(course, chapters, title=f"Séance {n} — {ch.title_text}", body=body, active="seances", desc=desc, readbar=True, canonical=f"/seance/{n:02d}/", has_report=has_report, body_attrs=f' data-part="{pidx}"') # ----------------------------------------------------------------------------- # Pages génériques # ----------------------------------------------------------------------------- def two_col_page(course, chapters, *, title, eyebrow, lead, content_html, side_html, active, canonical, extra_head="", extra_body="", has_report=True, body_class="", body_attrs=""): body = f"""
    {page_head(eyebrow, title, lead)}
    {content_html}
    """ return layout(course, chapters, title=strip_tags(title), body=body, active=active, canonical=canonical, extra_head=extra_head, extra_body=extra_body, has_report=has_report, body_class=body_class, body_attrs=body_attrs) def toc_side(toc, heading="Sommaire", extra="") -> str: items = "".join(f'
  • {(num + " ") if num else ""}{t}
  • ' for lvl, num, t, sid in toc if lvl <= 3) return f"

    {heading}

      {items}
    {extra}" def glossary_page(course, chapters, entries, intro_html, has_report=True) -> str: letters = sorted({en["letter"] for en in entries}) nav = "".join(f'{l}' for l in letters) parts, cur = [], None for en in entries: if en["letter"] != cur: cur = en["letter"] parts.append(f'

    {cur}

    ') parts.append(f'

    {en["term_html"]}

    {en["html"]}
    ') body = f"""
    {page_head('Annexe B', 'Glossaire', '')}
    {intro_html}
    {len(entries)} termes{icon('i-game')} Jouer avec le glossaire
    {''.join(parts)}
    """ return layout(course, chapters, title="Glossaire", body=body, active="reviser", canonical="/glossaire/", has_report=has_report, desc=f"Glossaire du cours {course['code']} : {len(entries)} termes définis, avec équivalents anglais.") def quiz_page(course, chapters, n_questions, has_report=True) -> str: body = f"""
    {page_head('S’exercer', 'Quiz et examens blancs', f'{n_questions} questions à choix multiples couvrant les 14 séances. Chaque réponse est expliquée et renvoie à la section des notes. Vos meilleurs scores sont conservés sur cet appareil ; un score de 70 % marque la séance comme terminée.')}

    Chargement…

    """ return layout(course, chapters, title="Quiz", body=body, active="exercer", canonical="/quiz/", has_report=has_report) def flash_page(course, chapters, n_cards, has_report=True) -> str: body = f"""
    {page_head('Réviser', 'Fiches de révision', f'{n_cards} fiches recto-verso générées à partir des définitions numérotées, du glossaire et des formules du cours. Retournez la carte, puis indiquez si vous la saviez : trois bonnes réponses successives la classent comme acquise.')}
    """ return layout(course, chapters, title="Fiches de révision", body=body, active="reviser", canonical="/fiches/", has_report=has_report) def tools_page(course, chapters, n_tools, has_report=True) -> str: body = f"""
    {page_head('Calculer', 'Calculateurs du cours', f'{n_tools} calculateurs reprennent les formules du manuel avec des données réalistes du marché québécois. Modifiez une hypothèse : le résultat, les étapes intermédiaires et les vérifications se recalculent instantanément.')}

    Repères pédagogiques tirés des cas du cours, pas des normes : à l’examen, utilisez toujours les données de l’énoncé.

    """ return layout(course, chapters, title="Calculateurs", body=body, active="exercer", canonical="/outils/", extra_body='', has_report=has_report) def training_page(course, chapters, n_gen, has_report=True) -> str: body = f"""
    {page_head('S’exercer', 'Entraînement', f'{n_gen} types de problèmes aux données tirées au hasard : chaque énoncé est nouveau, la réponse est vérifiée avec une tolérance d’arrondi et la solution est détaillée étape par étape. Enchaînez les bonnes réponses pour allonger votre série.')}

    Réponses acceptées : nombres avec ou sans espaces, virgule ou point décimal, symbole $ ou % facultatif.

    """ return layout(course, chapters, title="Entraînement", body=body, active="exercer", canonical="/entrainement/", extra_body='', has_report=has_report) def games_page(course, chapters, sequences, has_report=True) -> str: body = f"""
    {page_head('S’exercer', 'Jeux de révision', 'Trois jeux rapides construits à partir des définitions, du glossaire et des séquences du cours. Idéal pour cinq minutes de révision active.')}

    Chargement…

    """ seq_json = json.dumps(sequences, ensure_ascii=False) return layout(course, chapters, title="Jeux de révision", body=body, active="exercer", canonical="/jeux/", extra_body=f"", has_report=has_report) def progress_page(course, chapters, meta_json: str, has_report=True) -> str: body = f"""
    {page_head('Suivi', 'Ma progression', 'Tout ce que vous avez lu, réussi, coché et noté dans ce cours, sur cet appareil. Exportez votre progression pour la retrouver ailleurs.')}

    Chargement…

    """ return layout(course, chapters, title="Ma progression", body=body, active="exercer", canonical="/progression/", extra_body=f"", has_report=has_report) def map_page(course, chapters, map_json: str, has_report=True) -> str: body = f"""
    {page_head('Vue d’ensemble', 'Carte du cours', 'Les 14 séances disposées par partie. Survolez ou touchez une séance pour voir ses objectifs d’apprentissage et votre avancement ; cliquez pour l’ouvrir.')}
    {course_map_svg(course, chapters)}
    """ return layout(course, chapters, title="Carte du cours", body=body, active="reviser", canonical="/carte/", extra_body=f"", has_report=has_report) def search_page(course, chapters, has_report=True) -> str: body = f"""
    {page_head('Chercher', 'Recherche dans les notes', 'Chaque paragraphe, définition, formule, exemple, exercice, tableau, entrée de glossaire et section du rapport est indexé. Les résultats renvoient à l’emplacement exact.')}
    """ return layout(course, chapters, title="Recherche", body=body, active="ressources", canonical="/recherche/", has_report=has_report) def resources_page(course, chapters, rows, has_report=True) -> str: def fi(kind): return f'{kind.upper()}' groups = {} for r in rows: groups.setdefault(r["group"], []).append(r) parts = [] for g, items in groups.items(): trs = [] for r in items: target = ' target="_blank" rel="noopener"' if r["kind"] == "pdf" else "" desc = '
    ' + e(r["desc"]) + "
    " if r.get("desc") else "" trs.append(f'{fi(r["kind"])}{e(r["label"])}{desc}{r["size"]}{icon("i-download")} Télécharger') parts.append(f'

    {e(g)}

    {"".join(trs)}
    DocumentTaille
    ') body = f"""
    {page_head('Ressources', 'Téléchargements', 'Le manuel, le plan de cours, les diapositives des 14 séances, les énoncés de travaux pratiques, les gabarits Excel « Dashboard » et le rapport sur le marché de l’habitation avec ses données. Les solutionnaires et les questionnaires d’examen ne sont pas publiés ici.')}{''.join(parts)}
    """ return layout(course, chapters, title="Ressources", body=body, active="ressources", canonical="/ressources/", has_report=has_report) def slides_page(course, chapters, slides, has_report=True) -> str: tabs = "".join(f'' for s in slides) lis = "".join(f'
  • Séance {s["n"]} — {s["title_html"]} ({s["size"]})
  • ' for s in slides) body = f"""
    {page_head('Diapositives', 'Les 14 séances en diapositives', 'Les présentations Beamer projetées en classe, consultables ici ou téléchargeables en PDF. Les notes de cours en sont le prolongement écrit.')}
    {tabs}

    {icon('i-download')} Télécharger ce PDF

    Toutes les séances

    """ return layout(course, chapters, title="Diapositives", body=body, active="ressources", canonical="/diapositives/", has_report=has_report) def bib_page(course, chapters, entries_html, n, has_report=True, extra_title="") -> str: body = f"""
    {page_head('Références', 'Bibliographie et références', f'{n} ouvrages, normes, lois, publications statistiques et sources en ligne citées dans les notes de cours{extra_title} (style auteur-année).')}
    """ return layout(course, chapters, title="Bibliographie", body=body, active="ressources", canonical="/bibliographie/", has_report=has_report) def listing_page(course, chapters, *, eyebrow, title, lead, sections_html, active, canonical, side_html, has_report=True): body = f"""
    {page_head(eyebrow, title, lead)}
    {sections_html}
    """ return layout(course, chapters, title=title, body=body, active=active, canonical=canonical, has_report=has_report) def not_found_page(course, chapters, has_report=True) -> str: body = f"""
    Erreur 404

    Cette page n’existe pas

    La valeur d’une page introuvable est nulle — mais la substitution est facile.

    AccueilRechercher
    """ return layout(course, chapters, title="Page introuvable", body=body, canonical="/404.html", has_report=has_report) # ----------------------------------------------------------------------------- # Rapport — marché de l'habitation # ----------------------------------------------------------------------------- def report_index_page(course, chapters, sections, kpis_html: str, files: list, cover_html: str) -> str: toc = [] cur_app = None for s in sections: if s.appendix != cur_app: cur_app = s.appendix toc.append(f'
  • {"Annexes" if s.appendix else "Chapitres"}
  • ') toc.append(f'
  • {s.num}{s.title_html}
  • ') files_html = "".join(f'

    {icon("i-download")} {e(f["label"])}

    {e(f["desc"])}

    ' for f in files) body = f"""
    {page_head('Ressource transversale · juillet 2026', 'Le marché de l’habitation au Québec', 'Document de référence : analyse de l’indice des prix des propriétés MLS® (janvier 2005 – juin 2026) et du contexte macroéconomique canadien (FRED). Vingt ans de prix, six marchés québécois, types de propriétés, saisonnalité, risque, abordabilité — avec les données interactives.')}
    {cover_html}

    Métriques clés — {e(strip_tags(kpis_html.split('data-obs="')[1].split('"')[0]) if 'data-obs="' in kpis_html else 'dernière observation')}

    {kpis_html}
    Explorateur interactif

    Indices de prix MLS® : comparez les marchés

    Choisissez les marchés, la mesure et la période : indice composite, prix de référence, variation annuelle ou segments. Survolez la courbe pour lire les valeurs ; les indicateurs se recalculent sur la période affichée.

    Chargement des données…

    Explorateur interactif

    Cadre macroéconomique canadien (FRED)

    Taux d’intérêt, inflation, chômage, PIB, population et taux de change : les séries qui expliquent le cycle immobilier 2020–2026.

    Chargement des données…

    Table des matières

    Télécharger le rapport et ses données

    {files_html}
    """ return layout(course, chapters, title="Rapport — Le marché de l’habitation au Québec", body=body, active="ressources", canonical="/rapport/", body_class="report-page", desc="Rapport de référence sur le marché de l’habitation au Québec : indices de prix MLS® 2005–2026, contexte macroéconomique, six marchés québécois, abordabilité.") def report_section_page(course, chapters, sections, s, idx: int, body_html: str) -> str: prev_s = sections[idx - 1] if idx > 0 else None next_s = sections[idx + 1] if idx + 1 < len(sections) else None side_items = "".join(f'
  • {x.num} {x.title_html}
  • ' for x in sections) inner = "".join(f'
  • {num} {t}
  • ' for lvl, num, t, sid in s.toc if lvl <= 3) side = f'

    Dans cette section

      {inner}

    Rapport

      {side_items}
    ← Sommaire et métriques
    ' label = ("Annexe " if s.appendix else "Chapitre ") + s.num pager = f"""""" body = f"""
    {page_head(f'Le marché de l’habitation au Québec · {label}', s.title_html, '')}
    {body_html}{pager}
    """ return layout(course, chapters, title=f"{label} — {s.title_text} · Rapport", body=body, active="ressources", canonical=f"/rapport/{s.num}/", body_class="report-page", desc=f"{label} du rapport sur le marché de l’habitation au Québec : {s.title_text}.") # ----------------------------------------------------------------------------- # Séances vidéo (enregistrements Zoom) # ----------------------------------------------------------------------------- def _hms(t: float) -> str: t = max(0, int(round(t))) h, m, s = t // 3600, (t % 3600) // 60, t % 60 return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}" def videos_index_page(course, chapters, videos, has_report=True) -> str: by_n = {v["n"]: v for v in videos} cards = [] for n in range(1, 15): ch = chapters[n - 1] pidx, roman, ptitle, col = part_of(course, n) v = by_n.get(n) if v: m = v["meta"] cards.append(f"""
    {icon('i-clock')} {e(v['duration_text'])}{icon('i-play')}
    Séance {n:02d} · {e(m.get('date_text', m.get('date', '')))}

    {ch.title_html}

    {icon('i-list')} {len(v['chapters'])} chapitres{icon('i-cc')} sous-titres{icon('i-text')} transcription
    Non commencée
    """) else: cards.append(f"""
    {n:02d}
    Séance {n:02d}

    {ch.title_html}

    Enregistrement à venir
    """) total = sum(v["duration"] for v in videos) lead = (f"{len(videos)} séance{'s' if len(videos) > 1 else ''} enregistrée{'s' if len(videos) > 1 else ''} sur Zoom ({_hms(total)} au total). Chaque enregistrement est découpé en chapitres, sous-titré et accompagné de sa transcription synchronisée : cliquez sur une phrase pour y aller dans la vidéo, cherchez un mot, reprenez où vous étiez. " "Les enregistrements sont ajoutés après chaque séance.") body = f"""
    {page_head('Séances vidéo', 'Les enregistrements des séances', lead)}
    {''.join(cards)}

    Enregistrements destinés aux étudiantes et étudiants inscrits au cours {e(course['code'])}. La transcription est automatique (modèle Whisper) et n’a pas été corrigée : en cas de doute, la parole du professeur prévaut ; les notes de cours restent la référence écrite.

    """ return layout(course, chapters, title="Séances vidéo", body=body, active="videos", canonical="/videos/", has_report=has_report, desc=f"Enregistrements Zoom des séances du cours {course['code']} avec chapitres, transcription synchronisée et sous-titres.") def video_page(course, chapters, v, videos, has_report=True) -> str: n, nn, m = v["n"], v["nn"], v["meta"] pidx, roman, ptitle, col = part_of(course, n) ch = chapters[n - 1] base = f"/videos/{nn}" chaps = v["chapters"] prev_v = next((x for x in reversed(videos) if x["n"] < n), None) next_v = next((x for x in videos if x["n"] > n), None) start_t = m.get("start_t") # chapitres (panneau) chap_items = [] for c in chaps: kind = c.get("kind", "") link = f'{icon("i-book")}' if c.get("link") else "" chap_items.append(f'
  • {link}
  • ') # questions q_items = "".join(f'
  • {e(q["q"])}

    {e(q["a"])}

  • ' for q in m.get("questions", [])) # clavardage chat_items = "".join(f'
  • {e(c["time"])}{e(c["who"])}{e(c["text"])}
  • ' for c in v["chat"]) or '
  • Aucun message dans le clavardage.
  • ' # déroulé (tableau) rows = "".join(f'{e(c["title"])}{f"
    {e(c.get('desc', ''))}
    " if c.get("desc") else ""}{_hms(c["end"] - c["t"])}{f"{'Notes' if c['link'].startswith('/seance') else 'Ouvrir'} ↗" if c.get("link") else ""}' for c in chaps) # téléchargements def size(name): b = v["sizes"].get(name) if not b: return "" for unit in ("o", "Ko", "Mo", "Go"): if b < 1024: return f"{b:.0f} {unit}" if unit == "o" else f"{b:.1f} {unit}".replace(".", ",") b /= 1024 return "" dl = [("MP4", f"{base}/video.mp4", f"Vidéo complète (MP4, {size('video.mp4')})", "L’enregistrement Zoom original : écran partagé et voix."), ("VTT", f"{base}/captions.vtt", "Sous-titres (WebVTT)", f"{v['n_cues']} sous-titres synchronisés."), ("SRT", f"{base}/transcript.srt", "Sous-titres (SRT)", "Pour VLC, QuickTime ou tout lecteur."), ("TXT", f"{base}/transcript.txt", "Transcription horodatée (texte)", f"{format(v['words'], ',').replace(',', ' ')} mots, chapitres et minutage."), ("VTT", f"{base}/chapters.vtt", "Chapitres (WebVTT)", f"{len(chaps)} chapitres.")] if v["has_audio"]: dl.insert(1, ("M4A", f"{base}/audio.m4a", f"Audio seulement (M4A, {size('audio.m4a')})", "Pour écouter la séance en déplacement.")) if v["chat"]: dl.append(("TXT", f"{base}/chat.txt", "Clavardage Zoom", f"{len(v['chat'])} message{'s' if len(v['chat']) > 1 else ''}.")) dl_rows = "".join(f'{k}{e(l)}
    {e(d)}
    {icon("i-download")} Télécharger' for k, u, l, d in dl) related = "".join(f'{e(r["label"])}' for r in m.get("related", [])) summary = "".join(f"

    {e(p)}

    " for p in m.get("summary", [])) keypoints = "".join(f"
  • {k}
  • " for k in m.get("keypoints", [])) note = f'

    {icon("i-warning")} {e(m["note"])}

    ' if m.get("note") else "" hero = f"""
    {icon('i-video')} Séance vidéo Enregistrement {e(m.get('platform', 'Zoom'))} · {e(m.get('date_text', m.get('date', '')))} · {course['code']}

    Séance {n} — {ch.title_html}

    {e(course['title'])} · {e(course['term'])} · Simon-Pierre Boucher · UQO
    {icon('i-clock')} {e(v['duration_text'])} {icon('i-list')} {len(chaps)} chapitres {icon('i-cc')} sous-titres français {icon('i-text')} {format(v['words'], ',').replace(',', ' ')} mots transcrits {icon('i-slides')} {len(v['storyboard'])} vues d’écran
    """ player = f"""

    Écran au fil de la vidéo

    {len(v['storyboard'])} changements d’image détectés · cliquez pour y aller
    """ pager = f""" """ body = f"""
    {hero} {player}
    {note}
    Pour reconstruire la séance

    Ce qui s’est dit

    {summary}
    {icon('i-star')}À retenir de la séance
      {keypoints}
    Déroulé

    Les {len(chaps)} chapitres, minute par minute

    Chaque chapitre renvoie au passage de la vidéo et, lorsqu’il existe, à la section correspondante des notes de cours.

    {rows}
    DébutChapitreDurée
    Téléchargements

    Emporter la séance

    {dl_rows}
    Fichier

    Transcription automatique (Whisper large-v3-turbo) non corrigée : les noms propres, les chiffres et certains termes techniques peuvent être erronés. Enregistrement destiné aux personnes inscrites au cours ; ne pas rediffuser.

    {pager}
    """ desc = f"Enregistrement Zoom de la séance {n} du cours {course['code']} ({v['title']}) : {len(chaps)} chapitres, transcription synchronisée, sous-titres, résumé et points à retenir." return layout(course, chapters, title=f"Séance vidéo {n} — {v['title']}", body=body, active="videos", desc=desc, canonical=f"{base}/", has_report=has_report, body_attrs=f' data-part="{pidx}"', extra_head=f'')