SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

106.9 KB · 2,005 lines html
1<!--2  Author: Simon-Pierre Boucher <contact@spboucher.ai>3  File:   index.html4  Desc:   Frontend Sorti-Ka — recherche + fiches événement (SPA sans build,5          routeur History API, consomme /api). Design system Groupe KA6          « éditorial sharp » via ka-ui (src/ka/tokens.css, chargé en premier) :7          papier #f5f3ee · encre #141814 · vert #1c5c41 · ACCENT FRAMBOISE8          #d6336c · Space Grotesk / Inter / JetBrains Mono · bordures encre +9          ombres décalées + grain de film. Footer commun KaFooter et page10          /contact : contenu issu de src/ka/ecosystem.json (source de vérité —11          resynchroniser le HTML si le JSON change). SSR : sortika/seo.py12          injecte <head> et contenu dans les placeholders SSR_HEAD/SSR_BODY.13-->14<!DOCTYPE html>15<html lang="fr-CA">16<head>17<meta charset="utf-8">18<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">19<title>Sorti-Ka — Un service Groupe KA</title>20<meta name="description" content="Agrégateur indépendant de sorties et d'événements — concerts, festivals, spectacles, expositions, famille — dans les 17 régions du Québec, avec dates, lieux et gratuité.">21<link rel="icon" type="image/svg+xml" href="/favicon.svg">22<link rel="apple-touch-icon" href="/apple-touch-icon.png">23<meta property="og:image" content="https://www.sorti-ka.com/og.png">24<meta property="og:image:width" content="1200">25<meta property="og:image:height" content="630">26<meta name="twitter:card" content="summary_large_image">27<meta name="twitter:image" content="https://www.sorti-ka.com/og.png">28<!--SSR_HEAD-->29<link rel="preconnect" href="https://fonts.googleapis.com">30<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>31<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">32<link rel="stylesheet" href="/src/ka/tokens.css">33<style>34/* ka-ui/tokens.css (chargé ci-dessus) fournit palette, socle et composants35   communs ; ici : SEULEMENT l'accent framboise de Sorti·Ka + le CSS métier. */36:root{37  --accent:#d6336c; --accent-soft:#fbe0eb; --accent-deep:#a12551; --on-accent:#ffffff;38  /* alias rétro-compatibles — tout le CSS local en --lime suit l'accent */39  --lime:var(--accent); --lime-soft:var(--accent-soft);40}41h1,h2,h3,h4{margin:0}42a{text-decoration:none}43button{font-family:inherit}44.container{max-width:1240px;margin:0 auto;padding:0 24px}45@media(max-width:640px){.container{padding:0 16px}}46/* ---------- cartes & boutons signature ---------- */47.gk-card{background:var(--surface);border:1.5px solid var(--ink);48  border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}49.gk-hover{transition:transform .18s ease,box-shadow .18s ease}50.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}51.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;52  padding:10px 20px;border:1.5px solid var(--ink);border-radius:var(--r-ctl);53  font-family:var(--font-display);font-size:14px;font-weight:700;cursor:pointer;54  transition:background .15s ease,transform .1s ease,box-shadow .1s ease}55.btn:active{transform:translate(2px,2px);box-shadow:none!important}56.btn-primary{background:var(--ink);color:var(--accent);box-shadow:4px 4px 0 rgba(20,24,20,.25)}57.btn-primary:hover{background:var(--accent-deep)}58.btn-ghost{background:var(--surface);color:var(--ink);box-shadow:4px 4px 0 rgba(20,24,20,.12)}59.btn-ghost:hover{background:var(--accent-soft)}6061/* ---------- header / ticker ---------- */62.ticker{background:var(--ink);color:rgba(245,243,238,.85);overflow:hidden;63  font-family:var(--font-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;64  padding:7px 0;white-space:nowrap}65.ticker-track{display:inline-flex;animation:ticker 48s linear infinite;will-change:transform}66.ticker span{padding:0 26px;position:relative}67.ticker span::after{content:"◆";position:absolute;right:-6px;color:var(--lime);font-size:8px;top:3px}68@keyframes ticker{from{transform:translateX(0)}to{transform:translateX(-50%)}}69@media(prefers-reduced-motion:reduce){.ticker-track{animation:none}*{transition-duration:.01ms!important}}70/* header opaque, échelle --z-* (standard header/menu KA 2026-08-22) */71.header{position:sticky;top:0;z-index:var(--z-header,500);background:var(--paper);72  border-bottom:1.5px solid var(--ink)}73.header-inner{display:flex;align-items:center;flex-wrap:wrap;gap:6px 18px;height:auto;min-height:64px;padding:6px 0}74.header-inner .gk-badge{flex:none}75@media(max-width:760px){76  .header-inner{min-height:56px;padding:8px 0;gap:6px 10px}77  /* KA Nav v2 (2026-08-25) : la nav inline cède la place au hamburger + panneau78     plein écran fixed — menu visible peu importe la position de scroll */79  .nav{display:none}80  #auth{margin-left:auto}81  .ka-mnav-btn{display:flex}82}83.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;84  display:flex;align-items:baseline;line-height:1;cursor:pointer;position:relative}85/* extension tactile : le logo (98×26) atteint >=44px de cible sans changer le rendu */86.brand::after{content:"";position:absolute;inset:-8px}87.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 2px;margin-left:2px;transform:rotate(-1deg)}88.brand-tag{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;89  letter-spacing:.08em;color:var(--ink-3);margin-left:8px}90@media(max-width:900px){.brand-tag{display:none}}91.nav{margin-left:auto;display:flex;gap:6px}92.nav a{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px;93  border:1.5px solid transparent;border-radius:var(--r-ctl);94  font-family:var(--font-display);font-weight:700;font-size:13.5px;transition:all .15s ease}95.nav a:hover{border-color:var(--ink);background:var(--accent-soft)}9697/* ===== KA Nav v2 — menu mobile plein écran (standard 2026-08-25) =====98   Panneau position:fixed inset:0 (z-modal) → visible peu importe le scroll. */99.ka-mnav-btn{display:none;width:44px;height:44px;flex:none;100  flex-direction:column;align-items:center;justify-content:center;gap:5px;101  background:var(--paper);border:1.5px solid var(--ink);border-radius:999px;102  cursor:pointer;padding:0}103.ka-mnav-btn span{display:block;width:18px;height:2px;background:var(--ink);104  border-radius:2px;transition:transform .18s ease,opacity .14s ease}105.ka-mnav-btn[aria-expanded="true"] span:nth-child(1){transform:translateY(7px) rotate(45deg)}106.ka-mnav-btn[aria-expanded="true"] span:nth-child(2){opacity:0}107.ka-mnav-btn[aria-expanded="true"] span:nth-child(3){transform:translateY(-7px) rotate(-45deg)}108.ka-mnav{position:fixed;inset:0;z-index:var(--z-modal,900);background:var(--paper);109  display:none;flex-direction:column;overflow-y:auto;overscroll-behavior:contain}110.ka-mnav.open{display:flex;animation:ka-mnav-in .16s ease}111@keyframes ka-mnav-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}112.ka-mnav-top{display:flex;align-items:center;justify-content:space-between;flex:none;113  min-height:64px;padding:0 18px;border-bottom:2px solid var(--ink);background:var(--paper)}114.ka-mnav-close{width:44px;height:44px;flex:none;display:flex;align-items:center;115  justify-content:center;border:1.5px solid var(--ink);border-radius:999px;116  background:var(--ink);color:var(--lime);font-size:17px;font-weight:700;117  font-family:var(--font-display);cursor:pointer;padding:0;line-height:1}118.ka-mnav-links{display:flex;flex-direction:column;justify-content:center;flex:1;119  padding:28px 24px;counter-reset:mnav}120.ka-mnav-links a{display:flex;align-items:baseline;gap:14px;padding:17px 0;121  border-bottom:1.5px dashed rgba(20,24,20,.18);color:var(--ink)}122.ka-mnav-links a:last-child{border-bottom:0}123.ka-mnav-links a::before{counter-increment:mnav;content:"0" counter(mnav);124  font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--accent-deep)}125.ka-mnav-links a b{font-family:var(--font-display);font-weight:700;126  font-size:27px;line-height:1;letter-spacing:-.03em;text-transform:uppercase}127.ka-mnav-links a.active b{background:var(--ink);color:var(--lime);128  border-radius:6px;padding:4px 10px 6px}129.ka-mnav-foot{flex:none;border-top:2px solid var(--ink);padding:16px 24px;130  padding-bottom:max(20px,env(safe-area-inset-bottom))}131.ka-mnav-sec{display:flex;flex-wrap:wrap;gap:6px 20px;margin-top:12px;132  font-family:var(--font-mono);font-size:10.5px;text-transform:uppercase;133  letter-spacing:.06em}134.ka-mnav-sec a{color:var(--ink-3)}135html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}136.btn-ka{display:inline-flex;align-items:center;gap:8px;padding:8px 15px;min-height:38px;137  border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--ink);138  color:var(--lime);font-family:var(--font-display);font-weight:700;font-size:13px;139  cursor:pointer;box-shadow:3px 3px 0 rgba(20,24,20,.25);transition:background .15s ease}140.btn-ka:hover{background:var(--accent-deep)}141#auth{display:inline-flex;align-items:center;gap:10px;flex:none}142.ka-signup{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;143  letter-spacing:.06em;color:var(--ink-2);min-height:44px;display:inline-flex;align-items:center}144.ka-signup:hover{color:var(--accent-deep);text-decoration:underline}145.btn-ka .kdot{background:var(--accent);color:var(--on-accent);border-radius:4px;146  padding:0 5px;font-weight:800;transform:rotate(-1deg)}147.user-chip{display:inline-flex;align-items:center;gap:9px;border:1.5px solid var(--ink);148  border-radius:var(--r-pill,999px);background:var(--surface);padding:4px 12px 4px 5px;149  box-shadow:3px 3px 0 rgba(20,24,20,.12)}150.user-chip img{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}151.user-chip .uname{font-family:var(--font-display);font-weight:700;font-size:12.5px;max-width:140px;152  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}153.user-chip .kaid{font-family:var(--font-mono);font-size:9px;color:var(--ink-3);display:block;line-height:1}154.user-chip .out{border:none;background:none;cursor:pointer;font-family:var(--font-mono);155  font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px}156.user-chip .out:hover{text-decoration:underline}157@media(max-width:640px){.user-chip .uname{max-width:80px}}158159/* ---------- hero ---------- */160.hero{padding:56px 0 20px}161@media(max-width:640px){.hero{padding:34px 0 12px}}162.hero h1{font-size:clamp(34px,5.2vw,58px);font-weight:700;line-height:1.05;max-width:940px;margin-top:16px}163.hero p.lede{color:var(--ink-2);font-size:16.5px;max-width:660px;margin:18px 0 0}164.stat-row{display:flex;flex-wrap:wrap;gap:10px;margin-top:26px}165.stat-chip{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);166  padding:8px 14px;font-family:var(--font-mono);font-size:11.5px;color:var(--ink-2);167  display:flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.12)}168.stat-chip b{color:var(--ink);font-weight:700}169.stat-chip .pulse{width:8px;height:8px;border-radius:50%;background:var(--green);170  box-shadow:0 0 0 4px var(--lime-soft);animation:pulse 2.4s ease infinite}171@keyframes pulse{50%{box-shadow:0 0 0 7px rgba(28,92,65,.16)}}172173/* ---------- filtres ---------- */174.filterbar{padding:14px;margin:28px 0 6px}175.f-primary{display:flex;gap:10px;align-items:stretch;flex-wrap:wrap}176.f-search{flex:1 1 240px;min-width:0;max-width:460px;display:flex;align-items:center;gap:8px;177  border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface-2);178  padding:0 14px;min-height:46px;transition:background .15s ease,box-shadow .15s ease}179.f-search:focus-within{background:var(--surface);box-shadow:3px 3px 0 var(--lime)}180.f-search input{border:none;background:none;outline:none;flex:1;min-width:0;181  font-size:14px;color:var(--ink);font-family:inherit}182.f-ctl{display:flex;flex-direction:column;justify-content:center;gap:2px;183  border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);184  padding:6px 14px 5px;min-height:46px;cursor:pointer;transition:box-shadow .15s ease}185.f-ctl:focus-within{box-shadow:3px 3px 0 var(--lime)}186.f-ctl select{border:none;background:none;outline:none;font-family:var(--font-display);187  font-size:13.5px;font-weight:700;color:var(--ink);padding:0;margin-left:-2px;max-width:220px}188/* champs >=16px sur mobile : évite le zoom automatique d'iOS au focus */189@media(max-width:760px){190  .f-search input{font-size:16px}191  .f-ctl select{font-size:16px}192}193.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}194.chip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);195  padding:6px 12px;font-family:var(--font-display);font-size:12.5px;font-weight:700;196  text-transform:none;letter-spacing:0;min-height:34px;197  cursor:pointer;color:var(--ink);line-height:1;transition:all .12s ease}198.chip:hover{background:var(--accent-soft)}199.chip.on{background:var(--ink);color:var(--accent);box-shadow:3px 3px 0 rgba(20,24,20,.25)}200.chip.free.on{background:var(--green);border-color:var(--green);color:#fff}201202/* ---------- résultats ---------- */203.meta-line{display:flex;align-items:center;gap:10px;margin:20px 2px 10px;204  font-family:var(--font-mono);font-size:11.5px;text-transform:uppercase;205  letter-spacing:.08em;color:var(--ink-3)}206.meta-line b{color:var(--ink)}207.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;margin:8px 0 34px}208.card{overflow:hidden;display:flex;flex-direction:column;cursor:pointer}209.card-img{position:relative;height:150px;border-bottom:1.5px solid var(--ink);210  background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 12px,var(--surface-2) 12px,var(--surface-2) 24px);overflow:hidden}211.card-img img{width:100%;height:100%;object-fit:cover}212.badge-date{position:absolute;left:10px;bottom:10px;font-family:var(--font-mono);213  font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;214  color:var(--on-accent);background:var(--accent);border:1.5px solid var(--ink);215  border-radius:var(--r-ctl);padding:4px 9px;white-space:nowrap;box-shadow:2px 2px 0 var(--ink)}216.card-body{padding:15px 16px 14px;display:flex;flex-direction:column;gap:8px;flex:1}217.card-top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}218.badge-free{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;219  color:#fff;background:var(--green);border-radius:var(--r-ctl);padding:4px 8px;flex:none}220.badge-price{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;221  color:var(--ink);background:var(--amber-soft);border:1px solid var(--amber);222  border-radius:var(--r-ctl);padding:3px 8px;flex:none}223.badge-status{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;224  color:#fff;background:#b3261e;border:1px solid var(--ink);225  border-radius:var(--r-ctl);padding:4px 8px;flex:none}226.badge-status--soldout{background:#5f6368}227.badge-status--postponed{background:#a05a00}228.status-banner{margin:0 0 14px;padding:10px 14px;border:1.5px solid var(--ink);229  border-radius:var(--r-ctl);font-weight:700;color:#fff;background:#b3261e;230  box-shadow:2px 2px 0 var(--ink)}231.status-banner--soldout{background:#5f6368}232.status-banner--postponed{background:#a05a00}233.card h3{font-size:17px;font-weight:700;line-height:1.25}234.card .desc{font-size:12.5px;color:var(--ink-2);display:-webkit-box;235  -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}236.card .where{font-size:12.5px;color:var(--ink-3)}237.card .where b{color:var(--ink-2);font-weight:600}238.card .cats{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:4px}239.cat{font-family:var(--font-mono);font-size:9.5px;font-weight:700;letter-spacing:.06em;240  text-transform:uppercase;color:var(--ink-2);border:1px solid var(--line-strong);241  border-radius:var(--r-ctl);padding:2px 7px}242.card .src{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;243  letter-spacing:.04em;color:var(--ink-3);border-top:1px dashed var(--line-strong);244  padding-top:10px;margin-top:6px;display:flex;justify-content:space-between}245.card .src .go{color:var(--green);font-weight:700}246.more{display:block;margin:0 auto 54px}247.empty{padding:60px 10px;text-align:center;color:var(--ink-3);grid-column:1/-1}248.empty b{font-family:var(--font-display);font-size:19px;color:var(--ink);display:block;margin-bottom:6px}249250/* ---------- fiche événement ---------- */251.detail{padding:34px 0 50px}252.back{display:inline-flex;align-items:center;gap:8px;min-height:44px;font-family:var(--font-mono);253  font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;254  color:var(--green);cursor:pointer;margin-bottom:8px}255@media(pointer:coarse){.chip{min-height:44px}.user-chip .out{min-height:32px;padding:2px 8px}}256.back:hover{text-decoration:underline}257.detail-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:26px;align-items:start}258@media(max-width:860px){.detail-grid{grid-template-columns:1fr}}259.detail-hero{overflow:hidden}260.detail-hero .img{height:340px;border-bottom:1.5px solid var(--ink);261  background:repeating-linear-gradient(-45deg,var(--lime-soft),var(--lime-soft) 14px,var(--surface-2) 14px,var(--surface-2) 28px)}262.detail-hero .img img{width:100%;height:100%;object-fit:cover}263.detail-hero .body{padding:24px 26px 26px}264.detail-hero h1{font-size:clamp(26px,3.4vw,40px);line-height:1.08;margin:10px 0 14px}265.detail-hero .desc{color:var(--ink-2);font-size:15px;white-space:pre-line}266.detail-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}267.side{display:flex;flex-direction:column;gap:16px}268.side .box{padding:18px 20px}269.fact{display:flex;justify-content:space-between;gap:14px;padding:9px 0;border-bottom:1px dashed var(--line-strong);font-size:13.5px}270.fact:last-child{border-bottom:none}271.fact .k{font-family:var(--font-mono);font-size:10px;font-weight:700;272  text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);padding-top:3px;flex:none}273.fact .v{text-align:right;font-weight:600}274.side .btn{width:100%}275.maplink{font-family:var(--font-mono);font-size:11px;color:var(--green);font-weight:700;text-transform:uppercase}276.maplink:hover{text-decoration:underline}277.similar h2{font-size:22px;margin:44px 0 4px}278279/* ---------- sources / footer ---------- */280.sources{background:var(--surface-2);border-top:1.5px solid var(--ink);padding:44px 0 38px}281.sources h2{font-size:26px;margin-top:12px}282.sources p.lede2{color:var(--ink-2);font-size:14.5px;max-width:720px;margin:10px 0 0}283.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-top:22px}284.src-card{padding:16px 18px}285.src-card h4{font-size:15px;margin-bottom:5px}286.src-card .lic{font-family:var(--font-mono);font-size:9.5px;font-weight:700;287  color:var(--green);text-transform:uppercase;letter-spacing:.06em}288.src-card .n{font-size:12.5px;color:var(--ink-3);margin-top:6px}289.src-card .n b{color:var(--green-deep)}290.ka-footer{margin-top:0}291292/* ---------- page /contact ---------- */293.contact-page{padding:44px 0 64px}294.contact-page h1{font-size:clamp(30px,4.2vw,48px);line-height:1.06;margin-top:16px;max-width:820px}295.contact-page .lede{color:var(--ink-2);font-size:16px;max-width:660px;margin:16px 0 0}296.contact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));297  gap:18px;margin-top:28px}298.contact-card{padding:20px 22px;display:flex;flex-direction:column;gap:6px}299.contact-card a.mail{display:inline-flex;align-items:center;min-height:44px;300  font-family:var(--font-mono);font-weight:700;font-size:14px;color:var(--accent-deep);301  word-break:break-all}302.contact-card a.mail:hover{text-decoration:underline;text-underline-offset:4px}303.contact-note{margin-top:26px;padding:18px 20px;border-left:3px solid var(--accent);304  background:var(--surface);border-radius:0 var(--r-card) var(--r-card) 0;305  box-shadow:var(--shadow-flat);max-width:760px}306.contact-note b{color:var(--ink)}307.contact-sites{margin-top:36px}308.contact-sites ul{list-style:none;display:flex;flex-wrap:wrap;gap:10px;margin:14px 0 0;padding:0}309.contact-sites a{display:inline-flex;align-items:center;min-height:44px;padding:8px 14px;310  border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);311  font-family:var(--font-display);font-weight:700;font-size:13px;312  box-shadow:3px 3px 0 rgba(20,24,20,.12);transition:background .15s ease}313.contact-sites a:hover{background:var(--accent-soft)}314.contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px;315  color:var(--ink-3);margin-left:8px}316/* ---------- page /stats (module ka-stats, vanilla + SVG) ---------- */317.stats-page{padding:40px 0 60px}318@media(max-width:640px){.stats-page{padding:28px 0 44px}}319.st-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between}320.st-head h1{font-size:clamp(28px,4vw,44px);line-height:1.06;margin-top:12px}321.st-fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}322.st-pdf{display:inline-flex;gap:8px;flex-wrap:wrap}323.st-periods{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:20px}324.st-periods .input{width:150px;padding:8px 10px;font-size:13px;min-height:44px}325.st-custom{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}326.st-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr));gap:14px;margin-top:22px}327.st-kpi{padding:14px 16px;min-width:0}328.st-kpi .v{font-family:var(--font-display);font-weight:700;font-size:clamp(22px,2.4vw,30px);letter-spacing:-.02em}329.st-kpi .v small{font-size:.6em;color:var(--ink-2);font-weight:600}330.st-kpi .d{margin-top:8px;font-family:var(--font-mono);font-size:11px;font-weight:700}331.st-kpi .d .ctx{color:var(--ink-3);font-weight:500}332.st-sec{display:grid;gap:18px;margin-top:18px}333.st-2col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}334@media(max-width:900px){.st-2col{grid-template-columns:1fr}}335/* les enfants de grille doivent pouvoir rétrécir : le contenu large (tableaux336   nowrap, heatmap) défile alors DANS .tbl-wrap au lieu d'élargir la page */337.st-sec>div,.st-2col>div,.st-records>*{min-width:0;max-width:100%}338.st-fig .tbl-wrap{max-width:100%}339.st-fig{margin:0;padding:16px;min-width:0}340.st-fig figcaption{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center}341.st-fig figcaption b{font-family:var(--font-display);font-size:15px}342.st-fig svg{width:100%;height:auto;margin-top:10px;touch-action:pan-y}343.st-legend{display:flex;gap:10px;flex-wrap:wrap}344.st-legend button{display:inline-flex;align-items:center;gap:6px;border:0;background:none;cursor:pointer;345  font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;min-height:44px;color:var(--ink)}346.st-legend button.off{opacity:.4}347.st-legend .sw{width:18px;height:0;border-top:3px solid var(--accent)}348.st-legend .sw.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}349.st-tip{margin-top:8px;display:none}350.st-tip.show{display:inline-flex;align-items:center}351.st-bars{margin-top:12px;display:grid;gap:9px}352.st-bar-lbl{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}353.st-bar-lbl span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}354.st-bar-lbl b{font-family:var(--font-mono);font-size:11.5px;flex:none}355.st-bar-track{margin-top:3px;height:12px;background:rgba(20,24,20,.06);border-radius:0 3px 3px 0}356.st-bar-fill{height:100%;background:var(--accent);border:1px solid var(--ink);border-radius:0 3px 3px 0;box-sizing:border-box}357.st-donut-wrap{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-top:12px}358.st-donut-wrap svg{width:180px;max-width:100%;margin-top:0;flex:none}359.st-donut-wrap ul{list-style:none;margin:0;padding:0;display:grid;gap:6px;min-width:min(200px,100%);flex:1}360.st-donut-wrap li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}361.st-donut-wrap li .sw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);flex:none}362.st-donut-wrap li span.lb{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}363.st-donut-wrap li b{font-family:var(--font-mono);font-size:11px}364.st-tbl-head{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}365.st-tbl-head .input{max-width:240px;padding:8px 12px;font-size:13px}366.st-table{width:100%;border-collapse:collapse;font-size:13px}367.st-table th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);color:var(--paper);368  font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;user-select:none}369.st-table td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}370.st-table tr:nth-child(even){background:var(--surface-2)}371.st-tbl-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:8px}372.st-tbl-foot .pg{display:flex;gap:6px;align-items:center}373.st-records{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(280px,100%),1fr));gap:14px;margin-top:14px}374.st-record{padding:12px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;background:var(--surface-2)}375.st-record .lb{font-size:13px;color:var(--ink-2)}376.st-record .rv{text-align:right}377.st-record .rv b{font-family:var(--font-display);font-size:15px}378.st-record .rv .klabel{display:block}379/* v2 : menu PDF, sparklines KPI, jauges, résumé statistique, heatmap horaire */380.st-pdf-menu{position:relative;display:inline-block}381.st-pdf-drop{position:absolute;right:0;top:calc(100% + 6px);z-index:var(--z-dropdown,700);min-width:240px;382  display:flex;flex-direction:column;background:var(--paper);border:1.5px solid var(--ink);383  border-radius:10px;box-shadow:0 10px 28px rgba(20,24,20,.18);overflow:hidden}384.st-pdf-drop button{border:0;background:none;cursor:pointer;text-align:left;padding:12px 14px;385  font:inherit;font-size:13.5px;font-weight:600;color:var(--ink);min-height:44px;386  display:flex;justify-content:space-between;gap:10px;align-items:baseline}387.st-pdf-drop button+button{border-top:1px solid var(--line)}388.st-pdf-drop button:hover{background:var(--accent-soft)}389.st-pdf-drop small{font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}390/* ---------- v3 : constructeur de rapports personnalisés ---------- */391.stb-overlay{position:fixed;inset:0;z-index:var(--z-modal,900);background:rgba(20,24,20,.45);392  display:flex;align-items:flex-start;justify-content:center;padding:4vh 14px;overflow:auto}393.stb-panel{background:var(--surface);border:1px solid var(--ink);border-radius:12px;394  width:min(980px,100%);max-height:92vh;display:flex;flex-direction:column;395  box-shadow:0 18px 48px rgba(20,24,20,.3)}396.stb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;397  padding:16px 20px;border-bottom:1px solid var(--line)}398.stb-head h2{margin:0;font-size:18px}399.stb-body{display:grid;grid-template-columns:1fr 1fr;gap:0;overflow:auto;flex:1}400@media(max-width:760px){.stb-body{grid-template-columns:1fr}}401.stb-col{padding:14px 20px;min-width:0}402.stb-col+.stb-col{border-left:1px solid var(--line)}403@media(max-width:760px){.stb-col+.stb-col{border-left:0;border-top:1px solid var(--line)}}404.stb-col h3{margin:4px 0 10px;font-size:12px;font-family:var(--font-mono);405  text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)}406.stb-group{margin:0 0 6px;font-family:var(--font-mono);font-size:10.5px;font-weight:700;407  text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);margin-top:12px}408.stb-av{display:flex;justify-content:space-between;align-items:center;gap:8px;409  padding:7px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:13px}410.stb-av button{flex:none}411.stb-av.stb-in{opacity:.45}412.stb-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--ink);413  border-radius:8px;margin-bottom:6px;background:var(--surface-2);font-size:13px}414.stb-item .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}415.stb-item select{border:1px solid var(--line);border-radius:6px;background:var(--surface);416  padding:4px 6px;font-size:12px;max-width:150px}417.stb-item .mv{border:0;background:none;cursor:pointer;font-size:14px;padding:2px 4px;min-height:0}418.stb-item .mv:disabled{opacity:.25;cursor:default}419.stb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;420  padding:14px 20px;border-top:1px solid var(--line)}421.stb-tpl{display:flex;gap:8px;align-items:center;flex-wrap:wrap}422.stb-err{color:var(--danger);font-size:12.5px;margin:6px 0 0}423.stb-empty{border:1px dashed var(--line);border-radius:8px;padding:16px;color:var(--ink-3);424  font-size:13px;text-align:center}425.st-kpi .spark{margin-top:8px}426.st-kpi .spark svg{display:block;width:100%;height:26px}427.st-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(200px,100%),1fr));gap:14px;margin-top:18px}428.st-gauge{padding:14px 16px;display:flex;gap:14px;align-items:center;min-width:0}429.st-gauge svg{width:86px;flex:none}430.st-gauge .gv{font-family:var(--font-display);font-weight:700;font-size:21px;letter-spacing:-.02em}431.st-gauge .gv small{font-size:.62em;color:var(--ink-2)}432.st-sum{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:10px;padding-top:10px;border-top:1px dashed var(--line)}433.st-sum span{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em}434.st-sum b{color:var(--ink);font-size:11.5px;margin-left:4px}435.st-legend .sw.p1{border-top-style:dashed}436.st-legend .sw.p2{border-top-style:dotted}437.st-legend .sw.p3{border-top-style:double;border-bottom:1px solid var(--accent);height:3px}438.st-legend .sw.fill{width:12px;height:12px;border:1px solid var(--ink);border-radius:3px;background:var(--accent)}439.st-bar-lbl .bd{font-family:var(--font-mono);font-size:10px;font-weight:700;flex:none;margin-left:6px}440.st-empty{padding:20px;background:var(--surface-2);border-style:dashed}441.st-empty b{font-family:var(--font-display);font-size:14px}442.st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}443/* ---- favoris ♥ « Mon univers Ka » (hub groupe-ka.com) ---- */444.fav-btn{position:absolute;top:8px;right:8px;z-index:2;width:34px;height:34px;445  display:inline-flex;align-items:center;justify-content:center;cursor:pointer;446  background:var(--surface);border:1.5px solid var(--ink);border-radius:50%;447  color:var(--ink);padding:0;transition:transform .12s ease,background .12s ease}448.fav-btn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linejoin:round}449.fav-btn:hover{transform:scale(1.08);background:var(--accent-soft)}450.fav-btn.on{background:var(--accent);border-color:var(--accent-deep);color:var(--on-accent)}451.fav-btn.on svg{fill:currentColor}452.fav-btn.big{position:static;flex:none;width:44px;height:44px}453.fav-btn.big svg{width:21px;height:21px}454.d-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}455.d-title-row h1{min-width:0}456.fav-empty{grid-column:1/-1}457.fav-empty .btn-ka{margin-top:12px}458#ssr{display:none}459.hidden{display:none!important}460</style>461</head>462<body>463464<div id="ssr"><!--SSR_BODY--></div>465466<div class="ticker"><div class="ticker-track">467  <span>Concerts</span><span>Festivals</span><span>Humour</span><span>Théâtre</span>468  <span>Expositions</span><span>Danse</span><span>Sport</span><span>Famille</span>469  <span>Plein air</span><span>17 régions du Québec</span>470  <span>Concerts</span><span>Festivals</span><span>Humour</span><span>Théâtre</span>471  <span>Expositions</span><span>Danse</span><span>Sport</span><span>Famille</span>472  <span>Plein air</span><span>17 régions du Québec</span>473</div></div>474475<header class="header">476  <div class="container header-inner">477    <a class="brand" data-nav href="/">Sorti<span class="ka">·Ka</span></a>478    <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"479       aria-label="Un service Groupe KA — visiter le portail de l'écosystème">480      Un service <b>Groupe<span class="ka">KA</span></b></a>481    <span class="brand-tag">Sorties &amp; événements — Québec</span>482    <nav class="nav">483      <a href="/#sources">Sources</a>484      <a data-nav href="/favoris" title="Mes favoris — Mon univers Ka">♥ Favoris</a>485      <a data-nav href="/stats">Stats</a>486      <a data-nav href="/contact">Contact</a>487      <a href="/docs" target="_blank" rel="noopener">API</a>488    </nav>489    <span id="auth"></span>490    <button id="ka-mnav-btn" class="ka-mnav-btn" aria-label="Ouvrir le menu"491            aria-expanded="false" aria-controls="ka-mnav">492      <span></span><span></span><span></span>493    </button>494  </div>495</header>496497<!-- KA Nav v2 : panneau mobile plein écran (fixed inset:0 — visible peu importe le scroll) -->498<div id="ka-mnav" class="ka-mnav" role="dialog" aria-modal="true" aria-label="Menu">499  <div class="ka-mnav-top">500    <a class="brand" data-nav href="/">Sorti<span class="ka">·Ka</span></a>501    <button class="ka-mnav-close" data-ka-mnav-close aria-label="Fermer le menu">✕</button>502  </div>503  <nav class="ka-mnav-links">504    <a data-nav href="/"><b>Sorties</b></a>505    <a href="/#sources"><b>Sources</b></a>506    <a data-nav href="/favoris"><b>♥ Favoris</b></a>507    <a data-nav href="/stats"><b>Stats</b></a>508    <a data-nav href="/contact"><b>Contact</b></a>509    <a href="/docs" target="_blank" rel="noopener"><b>API</b></a>510  </nav>511  <div class="ka-mnav-foot">512    <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"513       aria-label="Un service Groupe KA — visiter le portail de l'écosystème">514      Un service <b>Groupe<span class="ka">KA</span></b></a>515    <div class="ka-mnav-sec">516      <a href="/doc/">Documentation</a>517      <a href="https://www.groupe-ka.com/conditions" target="_blank" rel="noopener noreferrer">Conditions</a>518      <a href="https://www.groupe-ka.com/confidentialite" target="_blank" rel="noopener noreferrer">Confidentialité</a>519    </div>520  </div>521</div>522523<!-- ======== VUE LISTE ======== -->524<main id="view-list">525  <section class="hero container">526    <span class="kicker">Agrégateur indépendant · mise à jour horaire</span>527    <h1>Toutes les sorties du Québec, <span class="hl">un seul endroit.</span></h1>528    <p class="lede">Concerts, festivals, spectacles, expositions, sport, famille — agrégés529       depuis des billetteries et des sources ouvertes officielles, dans les 17 régions.530       Chaque fiche renvoie vers la billetterie ou la source originale.</p>531    <div class="stat-row" id="stats"></div>532  </section>533534  <section class="container">535    <div class="filterbar gk-card">536      <div class="f-primary">537        <label class="f-search">🔎<input type="text" id="q" placeholder="Artiste, événement, salle, ville…"></label>538        <label class="f-ctl"><span class="klabel">Région</span>539          <select id="region"><option value="">Toutes les régions</option></select>540        </label>541        <label class="f-ctl"><span class="klabel">Quand</span>542          <select id="period">543            <option value="">À venir (tout)</option>544            <option value="today">Aujourd'hui</option>545            <option value="weekend">Ce week-end</option>546            <option value="7">7 prochains jours</option>547            <option value="30">30 prochains jours</option>548          </select>549        </label>550      </div>551      <div class="chips" id="cats"></div>552    </div>553554    <div class="meta-line"><span id="count">Chargement…</span></div>555    <section class="grid" id="grid"></section>556    <button class="more btn btn-primary" id="more" hidden>Afficher plus d'événements</button>557  </section>558559  <section class="sources" id="sources">560    <div class="container">561      <span class="kicker">Index fidèle · attribution systématique</span>562      <h2>Les sources</h2>563      <p class="lede2">Rien d'inventé : chaque événement est attribué à sa source et lié564         vers la fiche originale. Billetteries au robots.txt permissif et565         <b>données ouvertes officielles</b> (licences Creative Commons).</p>566      <div class="src-grid" id="src-grid"></div>567    </div>568  </section>569</main>570571<!-- ======== VUE FICHE ÉVÉNEMENT ======== -->572<main id="view-detail" class="hidden">573  <div class="container detail">574    <span class="back" id="back">← Toutes les sorties</span>575    <div class="detail-grid">576      <article class="gk-card detail-hero">577        <div class="img" id="d-img"></div>578        <div class="body">579          <span class="klabel" id="d-cats-label"></span>580          <div id="d-status"></div>581          <div class="d-title-row"><h1 id="d-title"></h1><span id="d-fav"></span></div>582          <div class="desc" id="d-desc"></div>583          <div class="detail-cats" id="d-cats"></div>584        </div>585      </article>586      <aside class="side">587        <div class="gk-card box" id="d-facts"></div>588        <a class="btn btn-primary" id="d-cta" target="_blank" rel="noopener">Billets · fiche originale ↗</a>589        <div class="gk-card box" id="d-src"></div>590      </aside>591    </div>592    <section class="similar">593      <span class="kicker">À voir aussi</span>594      <h2 id="d-similar-title">Événements similaires</h2>595      <div class="grid" id="d-similar"></div>596    </section>597  </div>598</main>599600<!-- ======== VUE FAVORIS (/favoris) — ♥ « Mon univers Ka » (hub groupe-ka.com) ======== -->601<main id="view-favoris" class="hidden">602  <section class="hero container">603    <span class="kicker">Mon univers Ka · synchronisés sur toutes les plateformes Groupe KA</span>604    <h1>Mes <span class="hl">favoris</span></h1>605    <p class="lede" id="fav-lede">Vos sorties et événements favoris, rattachés à votre compte KA ID.</p>606  </section>607  <section class="container">608    <div class="meta-line"><span id="fav-count"></span></div>609    <section class="grid" id="fav-grid"></section>610  </section>611</main>612613<!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== -->614<main id="view-stats" class="hidden">615  <div class="container stats-page">616    <div class="st-head">617      <div>618        <span class="kicker">Tableau de bord · données réelles</span>619        <h1>Statistiques <span class="hl">Sorti·Ka</span></h1>620        <p class="st-fresh" id="st-fresh"></p>621      </div>622      <div class="st-pdf">623        <button type="button" class="btn btn-primary" id="st-pdf-full">⬇ Rapport PDF complet</button>624        <span class="st-pdf-menu">625          <button type="button" class="btn btn-ghost" id="st-pdf-more"626                  aria-haspopup="true" aria-expanded="false">Autres rapports ▾</button>627          <span class="st-pdf-drop hidden" id="st-pdf-drop" role="menu">628            <button type="button" data-mode="synthese">Synthèse exécutive <small>2-3 pages</small></button>629            <button type="button" data-mode="tendances">Tendances &amp; évolution</button>630            <button type="button" data-mode="repartitions">Répartitions &amp; géographie</button>631            <button type="button" data-mode="donnees">Données détaillées <small>tableaux longs</small></button>632          </span>633        </span>634        <button type="button" class="btn btn-ghost" id="st-custom-btn">🛠 Rapport personnalisé</button>635      </div>636    </div>637    <div class="st-kpis" id="st-kpis"></div>638    <div class="st-periods" id="st-periods"></div>639    <div class="st-gauges" id="st-gauges"></div>640    <div class="st-sec" id="st-series"></div>641    <div class="st-sec" id="st-multi"></div>642    <div class="st-2col" id="st-breaks"></div>643    <div class="st-2col" id="st-geosrc"></div>644    <div class="st-sec" id="st-heat"></div>645    <div class="st-sec" id="st-hourly"></div>646    <div class="st-sec" id="st-tables"></div>647    <section id="st-records-wrap" class="hidden" style="margin-top:34px">648      <span class="kicker">Records &amp; faits marquants</span>649      <div class="st-records" id="st-records"></div>650    </section>651  </div>652</main>653654<!-- ======== VUE CONTACT (/contact) — contenu : src/ka/ecosystem.json ======== -->655<main id="view-contact" class="hidden">656  <div class="container contact-page">657    <span class="kicker">Contact · Groupe KA</span>658    <h1>Écrire au <span class="hl">Groupe KA</span></h1>659    <p class="lede">Sorti·Ka est un service du Groupe KA, holding québécois d'agrégateurs de660       produits et services entièrement automatisés. Trois adresses, selon votre besoin :</p>661662    <div class="contact-grid">663      <div class="contact-card gk-card">664        <span class="klabel">Projets, partenariats &amp; données</span>665        <a class="mail" href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>666      </div>667      <div class="contact-card gk-card">668        <span class="klabel">Médias &amp; questions générales</span>669        <a class="mail" href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>670      </div>671      <div class="contact-card gk-card">672        <span class="klabel">Légal, vie privée &amp; Loi 25</span>673        <a class="mail" href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>674      </div>675    </div>676677    <p class="contact-note"><b>Groupe KA est un agrégateur de contenu :</b> nous ne vendons rien,678       ne louons rien et ne sommes partie à aucune transaction. Pour les billets, contactez la679       billetterie ou l'organisateur indiqué sur chaque fiche.</p>680    <p class="contact-note">La création de compte <b>KA ID</b> se fait sur le hub681       <a href="https://www.groupe-ka.com/connexion" target="_blank" rel="noopener noreferrer"682          style="color:var(--accent-deep);font-weight:700">groupe-ka.com/connexion</a> ;683       chaque site de l'écosystème délègue sa connexion au hub.</p>684685    <div class="contact-sites">686      <span class="kicker">L'écosystème ·Ka</span>687      <ul>688        <li><a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">Groupe KA<small>le portail</small></a></li>689        <li><a href="https://www.trouve-ka.com" target="_blank" rel="noopener noreferrer">Trouve·Ka<small>recherche</small></a></li>690        <li><a href="https://www.lou-ka.com" target="_blank" rel="noopener noreferrer">Lou·Ka<small>logements</small></a></li>691        <li><a href="https://www.immo-ka.com" target="_blank" rel="noopener noreferrer">Immo·Ka<small>propriétés</small></a></li>692        <li><a href="https://www.vrai-prix.com" target="_blank" rel="noopener noreferrer">Vrai-Prix<small>valeur immo</small></a></li>693        <li><a href="https://www.auto-ka.com" target="_blank" rel="noopener noreferrer">Auto·Ka<small>autos usagées</small></a></li>694        <li><a href="https://www.fabri-ka.com" target="_blank" rel="noopener noreferrer">Fabri·Ka<small>fait au Québec</small></a></li>695        <li><a href="https://www.food-ka.com" target="_blank" rel="noopener noreferrer">Food·Ka<small>épicerie</small></a></li>696        <li><a href="https://www.resto-ka.com" target="_blank" rel="noopener noreferrer">Resto·Ka<small>restos</small></a></li>697        <li><a href="https://www.crea-ka.com" target="_blank" rel="noopener noreferrer">Créa·Ka<small>créateurs</small></a></li>698        <li><a href="https://www.api-ka.com" target="_blank" rel="noopener noreferrer">API·Ka<small>données API</small></a></li>699        <li><a href="https://www.job-ka.com" target="_blank" rel="noopener noreferrer">Job·Ka<small>emplois</small></a></li>700        <li><a href="https://www.ka-stats.com" target="_blank" rel="noopener noreferrer">Ka·Stats<small>statistiques</small></a></li>701      </ul>702    </div>703  </div>704</main>705706<!-- ======== FOOTER COMMUN GROUPE KA (ka-ui/KaFooter, siteId="sorti-ka") ======== -->707<footer class="ka-footer" id="contact">708  <div class="container">709    <a class="wordmark" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"710       aria-label="Groupe KA — le portail de l'écosystème">Groupe <span class="ka">KA</span></a>711    <p class="desc">Holding québécois d'agrégateurs de produits et services entièrement automatisés.712       Des centaines de connecteurs lisent les sites à la source, normalisent la donnée et se713       resynchronisent seuls. <b style="color:var(--paper)">Sorti·Ka</b> — Toutes les sorties,714       dans les 17 régions — est un service Groupe KA.</p>715    <p class="notice"><b>Groupe KA est un agrégateur de contenu : nous ne vendons rien,716       ne louons rien et ne sommes partie à aucune transaction.</b></p>717    <ul class="sites">718      <li><a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">Groupe KA</a></li>719      <li><a href="https://www.trouve-ka.com" target="_blank" rel="noopener noreferrer">Trouve·Ka</a></li>720      <li><a href="https://www.lou-ka.com" target="_blank" rel="noopener noreferrer">Lou·Ka</a></li>721      <li><a href="https://www.immo-ka.com" target="_blank" rel="noopener noreferrer">Immo·Ka</a></li>722      <li><a href="https://www.vrai-prix.com" target="_blank" rel="noopener noreferrer">Vrai-Prix</a></li>723      <li><a href="https://www.auto-ka.com" target="_blank" rel="noopener noreferrer">Auto·Ka</a></li>724      <li><a href="https://www.fabri-ka.com" target="_blank" rel="noopener noreferrer">Fabri·Ka</a></li>725      <li><a href="https://www.food-ka.com" target="_blank" rel="noopener noreferrer">Food·Ka</a></li>726      <li><a href="https://www.resto-ka.com" target="_blank" rel="noopener noreferrer">Resto·Ka</a></li>727      <li><a href="https://www.crea-ka.com" target="_blank" rel="noopener noreferrer">Créa·Ka</a></li>728      <li><a href="https://www.api-ka.com" target="_blank" rel="noopener noreferrer">API·Ka</a></li>729      <li><a href="https://www.job-ka.com" target="_blank" rel="noopener noreferrer">Job·Ka</a></li>730      <li><a href="https://www.ka-stats.com" target="_blank" rel="noopener noreferrer">Ka·Stats</a></li>731      <li><a href="https://www.valoplex.com" target="_blank" rel="noopener noreferrer">ValoPlex</a></li>732      <li><a href="https://www.ka2.bot" target="_blank" rel="noopener noreferrer">Ka2</a></li>733      <li><a href="https://www.ka4.bot" target="_blank" rel="noopener noreferrer">Ka4</a></li>734      <li><a href="https://www.ka6.bot" target="_blank" rel="noopener noreferrer">Ka6</a></li>735    </ul>736    <div class="contacts">737      <p style="margin:0"><a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>738        <span>Projets, partenariats &amp; données</span></p>739      <p style="margin:0"><a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>740        <span>Médias &amp; questions générales</span></p>741      <p style="margin:0"><a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>742        <span>Légal, vie privée &amp; Loi 25</span></p>743    </div>744    <p class="legal">Données : SIT Québec (MTO, CC 4.0) · Ville de Montréal (CC BY 4.0) ·745       Ville de Laval (CC 4.0 &amp; laval.ca) · Villes de Québec (Québec animée), Sherbrooke,746       Longueuil, Brossard &amp; Saguenay (Sorties Saguenay) · Tourisme Cantons-de-l'Est,747       Outaouais, Lanaudière &amp; Bas-Saint-Laurent · Place des Arts · LHJMQ ·748       Le point de vente · evenko · Atuvu.ca · La Vitrine · Eventbrite · Ticketmaster ·749       Bandsintown · salles officielles · Répertoire des municipalités (MAMH) ·750       lieux : © contributeurs OpenStreetMap (ODbL).</p>751    <p class="legal">© 2026 Groupe KA — Simon-Pierre Boucher752      · <a data-nav href="/contact">Contact</a>753      · <a href="/doc/">Documentation</a>754      · <a href="https://www.groupe-ka.com/conditions" target="_blank" rel="noopener noreferrer">Conditions d'utilisation</a>755      · <a href="https://www.groupe-ka.com/confidentialite" target="_blank" rel="noopener noreferrer">Politique de confidentialité</a>756      · <a href="https://www.groupe-ka.com/loi-25" target="_blank" rel="noopener noreferrer">Protection des renseignements personnels (Loi 25)</a>757      · <a href="https://www.groupe-ka.com/bots" target="_blank" rel="noopener noreferrer">Transparence des robots d'indexation</a></p>758  </div>759</footer>760761<script>762/* Sorti-Ka — frontend + routeur. Auteur : Simon-Pierre Boucher <contact@spboucher.ai> */763"use strict";764const $ = id => document.getElementById(id);765const state = { q:"", region:"", category:"", free:false, from:"", to:"", offset:0, limit:30, total:0 };766767const CAT_LABELS = {768  "festival":"Festivals","musique":"Musique","arts-scene":"Arts de la scène",769  "exposition-musee":"Expos & musées","cinema":"Cinéma","sport":"Sport",770  "plein-air":"Plein air","famille":"Famille","gastronomie":"Gastronomie",771  "marche-foire":"Marchés & foires","conference":"Conférences & ateliers",772  "communautaire":"Communautaire","patrimoine":"Patrimoine","autre":"Autre"773};774const SRC_LABELS = {775  "sitq":"SIT Québec — Ministère du Tourisme","montreal":"Ville de Montréal",776  "laval":"Ville de Laval","lepointdevente":"Le point de vente",777  "evenko":"evenko","atuvu":"Atuvu.ca","lavitrine":"La Vitrine","sherbrooke":"Ville de Sherbrooke",778  "brossard":"Ville de Brossard","ticketmaster":"Ticketmaster","bandsintown":"Bandsintown",779  "eventbrite":"Eventbrite","longueuil":"Ville de Longueuil",780  "cantonsdelest":"Tourisme Cantons-de-l'Est",781  "lavalweb":"Ville de Laval (laval.ca)","outaouais":"Tourisme Outaouais",782  "lanaudiere":"Tourisme Lanaudière","saguenaysorties":"Sorties Saguenay (Ville de Saguenay)",783  "bassaintlaurent":"Tourisme Bas-Saint-Laurent","quebecanimee":"Québec animée (Ville de Québec)",784  "placedesarts":"Place des Arts","lhjmq":"LHJMQ",785  "sallesjsonld":"Site officiel de la salle"786};787const MONTHS = ["jan","fév","mar","avr","mai","juin","juil","août","sep","oct","nov","déc"];788const MONTHS_FULL = ["janvier","février","mars","avril","mai","juin","juillet",789  "août","septembre","octobre","novembre","décembre"];790791function fmtDate(iso, full=false){792  if(!iso) return "Date à confirmer";793  const [y,m,d] = iso.split("-").map(Number);794  return `${d} ${(full?MONTHS_FULL:MONTHS)[m-1]} ${y}`;795}796function fmtTime(t){797  if(!t) return "";798  const [h,m] = t.split(":");799  return `${Number(h)} h ${m}`;800}801function dateBadge(ev, full=false){802  if(!ev.start_date) return "Date à confirmer";803  if(ev.end_date && ev.end_date !== ev.start_date)804    return `${fmtDate(ev.start_date, full)} → ${fmtDate(ev.end_date, full)}`;805  if(ev.start_time === "00:00")                 /* minuit = suspect, jamais affiché tel quel */806    return `${fmtDate(ev.start_date, full)} · heure non confirmée`;807  return fmtDate(ev.start_date, full)808    + (ev.start_time ? ` · ${fmtTime(ev.start_time)}`809       + (ev.end_time && ev.end_time !== ev.start_time ? ` – ${fmtTime(ev.end_time)}` : "") : "");810}811function esc(s){ const d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }812const STATUS_LABELS = {cancelled:"Annulé", postponed:"Reporté", soldout:"Complet"};813function statusBadge(ev){814  if(!ev.status || !STATUS_LABELS[ev.status]) return "";815  return `<span class="badge-status badge-status--${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`;816}817function priceBadge(ev){818  if(ev.is_free === true) return `<span class="badge-free">Gratuit</span>`;819  if(ev.price_min != null) return `<span class="badge-price">Dès ${ev.price_min.toFixed(2).replace(".",",")} $</span>`;820  if(ev.is_free === false) return `<span class="badge-price">Payant</span>`;821  return "";822}823824function card(ev){825  const where = [ev.venue, ev.city, ev.region].filter(Boolean)826    .map((x,i)=> i===1?`<b>${esc(x)}</b>`:esc(x)).join(" · ");827  const cats = (ev.categories||[]).filter(c=>c!=="autre").slice(0,3)828    .map(c=>`<span class="cat">${esc(CAT_LABELS[c]||c)}</span>`).join("");829  const img = ev.image830    ? `<img src="${esc(ev.image)}" alt="" loading="lazy" onerror="this.remove()">` : "";831  return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}">832    <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span>${favBtn(ev)}</div>833    <div class="card-body">834      <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div>835      ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""}836      <div class="where">${where||"Lieu à confirmer"}</div>837      <div class="cats">${cats}</div>838      <div class="src"><span>${SRC_LABELS[ev.source]||ev.source}</span><span class="go">Fiche →</span></div>839    </div>840  </a>`;841}842843function params(){844  const p = new URLSearchParams();845  if(state.q) p.set("q", state.q);846  if(state.region) p.set("region", state.region);847  if(state.category) p.set("category", state.category);848  if(state.free) p.set("free","true");849  if(state.from) p.set("from", state.from);850  if(state.to) p.set("to", state.to);851  p.set("limit", state.limit); p.set("offset", state.offset);852  return p.toString();853}854855async function load(reset=true){856  if(reset){ state.offset = 0; }857  const data = await (await fetch(`/api/events?${params()}`)).json();858  state.total = data.total;859  const html = data.events.map(card).join("");860  if(reset){861    $("grid").innerHTML = html ||862      `<div class="empty"><b>Aucun événement trouvé</b>Essayez d'élargir les filtres ou la période.</div>`;863  } else {864    $("grid").insertAdjacentHTML("beforeend", html);865  }866  $("count").innerHTML = `<b>${data.total.toLocaleString("fr-CA")}</b>&nbsp;événement${data.total>1?"s":""} à venir`;867  $("more").hidden = state.offset + data.events.length >= data.total;868  bindNav($("grid"));869}870871function setPeriod(v){872  const today = new Date(); const iso = d => d.toISOString().slice(0,10);873  state.from = ""; state.to = "";874  if(v==="today"){ state.from = iso(today); state.to = iso(today); }875  else if(v==="weekend"){876    const day = today.getDay();877    const sat = new Date(today);878    if(day !== 6 && day !== 0) sat.setDate(today.getDate() + (6 - day));879    const sun = new Date(sat); sun.setDate(sat.getDate() + (day === 0 ? 0 : 1));880    state.from = iso(day===6||day===0 ? today : sat); state.to = iso(sun);881  }882  else if(v){ const end = new Date(today); end.setDate(today.getDate()+Number(v)); state.from = iso(today); state.to = iso(end); }883}884885/* ---------- fiche événement ---------- */886async function showDetail(uid){887  let ev;888  try{ ev = await (await fetch(`/api/events/${encodeURIComponent(uid)}`)).json(); }889  catch(e){ go("/"); return; }890  if(ev.detail){ go("/"); return; }891892  document.title = `${ev.title} — ${ev.city || "Québec"} | Sorti·Ka`;893  $("d-img").innerHTML = ev.image894    ? `<img src="${esc(ev.image)}" alt="" onerror="this.remove()">` : "";895  $("d-cats-label").textContent = (ev.categories||[]).filter(c=>c!=="autre")896    .map(c=>CAT_LABELS[c]||c).join(" · ") || "Événement";897  $("d-status").innerHTML = ev.status && STATUS_LABELS[ev.status]898    ? `<div class="status-banner status-banner--${esc(ev.status)}">${899        ev.status==="cancelled" ? "Cet événement est annulé"900        : ev.status==="postponed" ? "Cet événement est reporté — vérifiez la nouvelle date chez la source"901        : "Complet — plus de billets disponibles"}</div>` : "";902  $("d-title").textContent = ev.title;903  $("d-fav").innerHTML = favBtn(ev, true);904  $("d-desc").textContent = ev.description || "";905  $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40)906    .map(c=>`<span class="cat">${esc(c)}</span>`).join("");907908  const facts = [];909  facts.push(["Quand", dateBadge(ev, true)]);910  if(ev.artists && ev.artists.length)911    facts.push(["À l'affiche", ev.artists.map(esc).join(", ")]);912  if(ev.venue) facts.push(["Lieu", esc(ev.venue)]);913  if(ev.address) facts.push(["Adresse", esc(ev.address)]);914  if(ev.city) facts.push(["Ville", esc(ev.city)]);915  if(ev.region) facts.push(["Région", esc(ev.region)]);916  if(ev.is_free === true) facts.push(["Prix", `<span class="badge-free">Gratuit</span>`]);917  else if(ev.price_min != null) facts.push(["Prix", `Dès ${ev.price_min.toFixed(2).replace(".",",")} $ CAD`]);918  else if(ev.is_free === false) facts.push(["Prix", "Payant — voir la billetterie"]);919  if(ev.price_label) facts.push(["Détail prix", esc(ev.price_label)]);920  if(ev.audience) facts.push(["Public", esc(ev.audience)]);921  if(ev.organizer) facts.push(["Organisé par", esc(ev.organizer)]);922  if(ev.lat && ev.lng) facts.push(["Carte",923    `<a class="maplink" target="_blank" rel="noopener" href="https://www.google.com/maps?q=${ev.lat},${ev.lng}">Voir sur la carte ↗</a>`]);924  $("d-facts").innerHTML = facts.map(([k,v]) =>925    `<div class="fact"><span class="k">${k}</span><span class="v">${v}</span></div>`).join("");926927  $("d-cta").href = ev.url;928  $("d-src").innerHTML = `<span class="klabel">Source & attribution</span>929    <div style="margin-top:6px;font-size:13px">${SRC_LABELS[ev.source]||ev.source}</div>930    <div style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3);margin-top:4px">931      Capté le ${new Date((ev.updated_at||0)*1000).toLocaleDateString("fr-CA")} ·932      identifiant ${esc(ev.uid)}</div>`;933934  // événements similaires : même catégorie prioritaire, sinon même ville935  const cat = (ev.categories||[]).filter(c=>c!=="autre")[0] || "";936  const q = new URLSearchParams({limit: 7});937  if(cat) q.set("category", cat); else if(ev.city) q.set("city", ev.city);938  if(ev.region && cat) q.set("region", ev.region);939  try{940    const sim = await (await fetch(`/api/events?${q}`)).json();941    const others = sim.events.filter(e=>e.uid!==ev.uid).slice(0,6);942    $("d-similar").innerHTML = others.map(card).join("") ||943      `<div class="empty">Rien de similaire pour l'instant.</div>`;944    bindNav($("d-similar"));945  }catch(e){ $("d-similar").innerHTML = ""; }946947  show("view-detail");948  window.scrollTo({top:0});949}950951/* ==================== vue /stats — module ka-stats (vanilla + SVG) =========952   Reproduit le kit ka-ui/stats/kacharts.tsx : cartes KPI (delta ▲/▼),953   sélecteur de période en chips, courbe avec infobulle + légende cliquable,954   barres horizontales, anneau, heatmap calendrier 26 semaines, tableaux955   triables/recherche/pagination 25, records, bouton PDF. Zéro dépendance. */956const ST = { period:"30j", from:"", to:"", data:null, busy:false };957const ST_PERIODS = [["auj","Aujourd'hui"],["7j","7 jours"],["30j","30 jours"],958  ["3m","3 mois"],["6m","6 mois"],["12m","12 mois"],["annee","Année en cours"],["tout","Tout"]];959const fmtI = n => Math.round(n).toLocaleString("fr-CA");960const fmtN = n => Number.isInteger(n) ? fmtI(n)961  : n.toLocaleString("fr-CA",{maximumFractionDigits:2});962const ST_SHADES = [1,.78,.58,.42,.3,.22,.15,.1];963964function stEmpty(title){965  return `<div class="gk-card st-empty"><b>${esc(title)}</b>966    <p class="klabel" style="margin:6px 0 0">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;967}968969function stParams(mode){970  const p = new URLSearchParams();971  p.set("period", ST.period);972  if(ST.from && ST.to){ p.set("from", ST.from); p.set("to", ST.to); }973  if(mode) p.set("mode", mode);974  return p.toString();975}976977async function stFetch(){978  if(ST.busy) return;979  ST.busy = true;980  $("st-kpis").innerHTML = `<div class="st-loading">Chargement des statistiques…</div>`;981  try{982    ST.data = await (await fetch(`/api/stats/dashboard?${stParams()}`)).json();983    stRender();984  }catch(e){985    $("st-kpis").innerHTML = stEmpty("Statistiques indisponibles");986  }987  ST.busy = false;988}989990/* ---- sélecteur de période (chips + plage personnalisée) ---- */991function stRenderPeriods(){992  const custom = !!(ST.from && ST.to);993  $("st-periods").innerHTML = ST_PERIODS.map(([id,lab]) =>994    `<button type="button" class="chip${!custom && ST.period===id ? " on":""}" data-p="${id}" aria-pressed="${!custom && ST.period===id}">${lab}</button>`).join("")995    + `<span class="st-custom">996        <input class="input" type="date" id="st-from" value="${ST.from}" aria-label="Du">997        <span class="klabel">au</span>998        <input class="input" type="date" id="st-to" value="${ST.to}" aria-label="Au"></span>`;999  $("st-periods").querySelectorAll(".chip[data-p]").forEach(ch => ch.onclick = () => {1000    ST.period = ch.dataset.p; ST.from = ""; ST.to = "";1001    stRenderPeriods(); stFetch();1002  });1003  const onDate = () => {1004    ST.from = $("st-from").value; ST.to = $("st-to").value;1005    if(ST.from && ST.to){ stRenderPeriods(); stFetch(); }1006  };1007  $("st-from").onchange = onDate; $("st-to").onchange = onDate;1008}10091010/* ---- sparkline (tendance dans les cartes KPI) ---- */1011function stSpark(points){1012  const pts = (points||[]).filter(p=>typeof p.v==="number");1013  if(pts.length < 2) return "";1014  const W=120, H=26, n=pts.length;1015  const vmax = Math.max(...pts.map(p=>p.v), 1), vmin = Math.min(0, ...pts.map(p=>p.v));1016  const X = i => 2 + (W-4)*i/(n-1);1017  const Y = v => 2 + (H-4)*(1-(v-vmin)/((vmax-vmin)||1));1018  const line = pts.map((p,i)=>`${i?"L":"M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");1019  return `<div class="spark" aria-hidden="true"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">1020    <path d="${line} L${W-2},${H-1} L2,${H-1} Z" fill="var(--accent)" fill-opacity=".12" stroke="none"/>1021    <path d="${line}" fill="none" stroke="var(--accent)" stroke-width="1.6"/></svg></div>`;1022}10231024/* ---- statistiques de série : min / max / moyenne / médiane / écart-type ---- */1025function stSummary(points, unit){1026  const vs = (points||[]).map(p=>p.v).filter(v=>typeof v==="number");1027  if(vs.length < 5) return "";1028  const n = vs.length, mean = vs.reduce((s,v)=>s+v,0)/n;1029  const sorted = [...vs].sort((a,b)=>a-b);1030  const med = n%2 ? sorted[(n-1)/2] : (sorted[n/2-1]+sorted[n/2])/2;1031  const sd = Math.sqrt(vs.reduce((s,v)=>s+(v-mean)**2,0)/n);1032  const u = unit ? ` ${esc(unit)}` : "";1033  const f = v => fmtN(Math.round(v*10)/10);1034  return `<div class="st-sum">1035    <span>min<b>${f(sorted[0])}${u}</b></span><span>max<b>${f(sorted[n-1])}${u}</b></span>1036    <span>moyenne<b>${f(mean)}${u}</b></span><span>médiane<b>${f(med)}${u}</b></span>1037    <span>écart-type<b>${f(sd)}</b></span></div>`;1038}10391040/* ---- KPI (delta ▲/▼ + sparkline) ---- */1041function stKpi(k){1042  let delta = "";1043  if(k.delta_pct !== null && k.delta_pct !== undefined){1044    const up = (k.direction || (k.delta_pct >= 0 ? "up" : "down")) === "up";1045    delta = `<div class="d" style="color:${up?"var(--green)":"var(--danger)"}">1046      ${up?"▲":"▼"} ${k.delta_pct>=0?"+":""}${fmtN(k.delta_pct)} % <span class="ctx">vs période préc.</span></div>`;1047  }1048  return `<article class="gk-card st-kpi">1049    <div class="v">${typeof k.value === "number" ? fmtN(k.value) : esc(k.value)}${k.unit?` <small>${esc(k.unit)}</small>`:""}</div>1050    <div class="klabel" style="margin-top:6px">${esc(k.label)}</div>${delta}${stSpark(k.spark)}</article>`;1051}10521053/* ---- jauge demi-arc (taux, couvertures, complétude) ---- */1054function stGauge(g){1055  const max = g.max || 100, frac = Math.max(0, Math.min(1, g.value/max));1056  const R = 34, C = Math.PI*R;1057  return `<article class="gk-card st-gauge" role="img"1058      aria-label="${esc(g.label)} : ${fmtN(g.value)}${g.unit?` ${esc(g.unit)}`:""} sur ${fmtN(max)}">1059    <svg viewBox="0 0 86 50">1060      <path d="M 9 44 A ${R} ${R} 0 0 1 77 44" fill="none" stroke="rgba(20,24,20,.09)" stroke-width="9" stroke-linecap="round"/>1061      <path d="M 9 44 A ${R} ${R} 0 0 1 77 44" fill="none" stroke="var(--accent)" stroke-width="9" stroke-linecap="round"1062        stroke-dasharray="${(frac*C).toFixed(1)} ${C.toFixed(1)}"/>1063      <path d="M 9 44 A ${R} ${R} 0 0 1 77 44" fill="none" stroke="var(--ink)" stroke-opacity=".25" stroke-width="1"/>1064    </svg>1065    <div><div class="gv">${fmtN(g.value)}${g.unit?`<small> ${esc(g.unit)}</small>`:""}</div>1066      <div class="klabel" style="margin-top:4px">${esc(g.label)}</div></div></article>`;1067}10681069/* ---- courbe (infobulle au survol + légende cliquable + N-1) ---- */1070function stLine(host, serie){1071  const stt = { cur:true, cmp:true };1072  const W=720, H=240, PL=54, PR=10, PT=14, PB=26;1073  const pts = serie.points || [];1074  if(pts.length < 2){ host.innerHTML = stEmpty(serie.title); return; }1075  const X = (i,n) => PL + (W-PL-PR)*i/(n-1);1076  const render = () => {1077    const cmp = stt.cmp && serie.compare && serie.compare.length > 1 ? serie.compare : null;1078    const all = [...(stt.cur?pts:[]), ...(cmp||[])];1079    const vmax = Math.max(...all.map(p=>p.v), 1);1080    const vmin = Math.min(0, ...all.map(p=>p.v));1081    const Y = v => PT + (H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));1082    const path = s => s.map((p,i)=>`${i?"L":"M"}${X(i,s.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");1083    let grid = "";1084    for(let g=0;g<5;g++){1085      const y = PT + (H-PT-PB)*g/4, v = vmax-(vmax-vmin)*g/4;1086      grid += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>1087        <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtI(v)}</text>`;1088    }1089    const xlab = [0, Math.floor(pts.length/2), pts.length-1].map(i =>1090      `<text x="${X(i,pts.length)}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`).join("");1091    host.innerHTML = `<figure class="gk-card st-fig">1092      <figcaption><b>${esc(serie.title)}</b>1093        <span class="st-legend">1094          <button type="button" data-l="cur" class="${stt.cur?"":"off"}" aria-pressed="${stt.cur}"><span class="sw"></span>Période courante</button>1095          ${serie.compare?`<button type="button" data-l="cmp" class="${stt.cmp?"":"off"}" aria-pressed="${stt.cmp}"><span class="sw cmp"></span>Période comparée</button>`:""}1096        </span></figcaption>1097      <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(serie.title)}">1098        ${grid}${xlab}1099        ${cmp?`<path d="${path(cmp)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`:""}1100        ${stt.cur?`<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`:""}1101        <g data-marker style="visibility:hidden">1102          <line y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/>1103          <circle r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/>1104        </g>1105        <rect x="${PL}" y="0" width="${W-PL-PR}" height="${H}" fill="transparent"/>1106      </svg>1107      <p class="chip st-tip"></p>${stSummary(pts, serie.unit)}</figure>`;1108    const svg = host.querySelector("svg"), mk = svg.querySelector("[data-marker]"),1109      mline = mk.querySelector("line"), mdot = mk.querySelector("circle"),1110      tip = host.querySelector(".st-tip");1111    const move = clientX => {1112      const r = svg.getBoundingClientRect();1113      const fx = (clientX - r.left)/r.width*W;1114      const i = Math.min(pts.length-1, Math.max(0, Math.round((fx-PL)/(W-PL-PR)*(pts.length-1))));1115      const x = X(i, pts.length);1116      mk.style.visibility = "visible";1117      mline.setAttribute("x1",x); mline.setAttribute("x2",x);1118      mdot.setAttribute("cx",x); mdot.setAttribute("cy",Y(pts[i].v));1119      tip.classList.add("show");1120      tip.innerHTML = `${esc(pts[i].t)} — <b>&nbsp;${fmtN(pts[i].v)}${serie.unit?` ${esc(serie.unit)}`:""}</b>`1121        + (cmp && cmp[i] ? `<span style="color:var(--ink-3)">&nbsp;· N-1 : ${fmtN(cmp[i].v)}</span>` : "");1122    };1123    svg.addEventListener("mousemove", e => move(e.clientX));1124    svg.addEventListener("touchstart", e => move(e.touches[0].clientX), {passive:true});1125    svg.addEventListener("touchmove", e => move(e.touches[0].clientX), {passive:true});1126    svg.addEventListener("mouseleave", () => { mk.style.visibility="hidden"; tip.classList.remove("show"); });1127    host.querySelectorAll(".st-legend button").forEach(b => b.onclick = () => {1128      stt[b.dataset.l] = !stt[b.dataset.l];1129      if(!stt.cur && !stt.cmp) stt[b.dataset.l] = true;1130      render();1131    });1132  };1133  render();1134}11351136/* ---- barres horizontales (delta_pct optionnel par barre) ---- */1137function stBars(host, title, items, unit){1138  const rows = (items||[]).filter(r=>typeof r.value==="number").slice(0,14);1139  if(!rows.length){ host.innerHTML = stEmpty(title); return; }1140  const max = Math.max(...rows.map(r=>r.value), 1);1141  const delta = r => (r.delta_pct === null || r.delta_pct === undefined) ? "" :1142    `<span class="bd" style="color:${r.delta_pct>=0?"var(--green)":"var(--danger)"}">${r.delta_pct>=0?"▲ +":"▼ "}${fmtN(r.delta_pct)} %</span>`;1143  host.innerHTML = `<figure class="gk-card st-fig">1144    <figcaption><b>${esc(title)}</b></figcaption>1145    <div class="st-bars">${rows.map(r=>`1146      <div title="${esc(r.label)} — ${fmtN(r.value)}${unit?` ${unit}`:""}">1147        <div class="st-bar-lbl"><span>${esc(r.label)}</span><b>${fmtN(r.value)}${unit?` ${esc(unit)}`:""}</b>${delta(r)}</div>1148        <div class="st-bar-track"><div class="st-bar-fill" style="width:${Math.max(r.value/max*100,1)}%"></div></div>1149      </div>`).join("")}</div></figure>`;1150}11511152/* ---- barres verticales (volumes quotidiens & histogrammes) ---- */1153function stVBar(host, title, bins, unit, sub){1154  const rows = (bins||[]).filter(b=>typeof b.value==="number");1155  if(!rows.length || !rows.some(b=>b.value>0)){ host.innerHTML = stEmpty(title); return; }1156  const W=720, H=240, PL=54, PR=10, PT=14, PB=40;1157  const max = Math.max(...rows.map(b=>b.value), 1);1158  const n = rows.length, bw = (W-PL-PR)/n;1159  let grid = "";1160  for(let g=0; g<5; g++){1161    const y = PT + (H-PT-PB)*g/4, v = max*(4-g)/4;1162    grid += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>1163      <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtI(v)}</text>`;1164  }1165  const few = n <= 12;1166  const bars = rows.map((b,i)=>{1167    const h = (H-PT-PB)*b.value/max, x = PL + i*bw;1168    const lab = few ? `<text x="${x+bw/2}" y="${H-PB+14}" text-anchor="middle" font-size="9.5"1169        fill="var(--ink-3)" font-family="var(--font-mono)">${esc(String(b.label).slice(0,12))}</text>` :1170      (i===0 || i===n-1 || i===Math.floor(n/2) ?1171        `<text x="${x+bw/2}" y="${H-PB+14}" text-anchor="middle" font-size="9.5"1172          fill="var(--ink-3)" font-family="var(--font-mono)">${esc(String(b.label))}</text>` : "");1173    return `<rect x="${(x+bw*0.12).toFixed(1)}" y="${(H-PB-h).toFixed(1)}" width="${(bw*0.76).toFixed(1)}"1174      height="${Math.max(h, b.value>0?1.5:0).toFixed(1)}" fill="var(--accent)" stroke="var(--ink)" stroke-width=".8">1175      <title>${esc(b.label)} — ${fmtN(b.value)}${unit?` ${unit}`:""}</title></rect>${lab}`;1176  }).join("");1177  host.innerHTML = `<figure class="gk-card st-fig">1178    <figcaption><b>${esc(title)}</b>${sub?` <span class="klabel">${esc(sub)}</span>`:""}</figcaption>1179    <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(title)}">${grid}${bars}</svg></figure>`;1180}11811182/* ---- multi-courbes ≤ 4 séries : motifs de trait distincts + légende ---- */1183const ST_DASH = ["", "7 4", "2 4", "10 4 2 4"];1184function stMulti(host, ms){1185  const sers = (ms.series||[]).slice(0,4).filter(s=>(s.points||[]).length>1);1186  if(!sers.length){ host.innerHTML = stEmpty(ms.title); return; }1187  const on = sers.map(()=>true);1188  const W=720, H=260, PL=54, PR=10, PT=14, PB=26;1189  const n = Math.max(...sers.map(s=>s.points.length));1190  const X = i => PL + (W-PL-PR)*i/(n-1);1191  const render = () => {1192    const vis = sers.filter((_,i)=>on[i]);1193    const all = vis.flatMap(s=>s.points.map(p=>p.v));1194    const vmax = Math.max(...(all.length?all:[1]), 1), vmin = Math.min(0, ...(all.length?all:[0]));1195    const Y = v => PT + (H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));1196    let grid = "";1197    for(let g=0; g<5; g++){1198      const y = PT + (H-PT-PB)*g/4, v = vmax-(vmax-vmin)*g/4;1199      grid += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>1200        <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtI(v)}</text>`;1201    }1202    const ts = sers[0].points;1203    const xlab = [0, Math.floor(ts.length/2), ts.length-1].map(i =>1204      `<text x="${X(i)}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(ts[i].t)}</text>`).join("");1205    const paths = sers.map((s,si)=> !on[si] ? "" :1206      `<path d="${s.points.map((p,i)=>`${i?"L":"M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("")}"1207        fill="none" stroke="var(--accent)" stroke-opacity="${(1-si*0.16).toFixed(2)}" stroke-width="2.2"1208        ${ST_DASH[si]?`stroke-dasharray="${ST_DASH[si]}"`:""}/>`).join("");1209    host.innerHTML = `<figure class="gk-card st-fig">1210      <figcaption><b>${esc(ms.title)}</b>1211        <span class="st-legend">${sers.map((s,i)=>1212          `<button type="button" data-i="${i}" class="${on[i]?"":"off"}" aria-pressed="${on[i]}">1213            <span class="sw ${i?`p${i}`:""}"></span>${esc(s.label)}</button>`).join("")}</span></figcaption>1214      <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(ms.title)}">1215        ${grid}${xlab}${paths}1216        <g data-marker style="visibility:hidden">1217          <line y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/></g>1218        <rect x="${PL}" y="0" width="${W-PL-PR}" height="${H}" fill="transparent"/>1219      </svg><p class="chip st-tip"></p></figure>`;1220    const svg = host.querySelector("svg"), mk = svg.querySelector("[data-marker]"),1221      mline = mk.querySelector("line"), tip = host.querySelector(".st-tip");1222    const move = clientX => {1223      const r = svg.getBoundingClientRect();1224      const fx = (clientX - r.left)/r.width*W;1225      const i = Math.min(n-1, Math.max(0, Math.round((fx-PL)/(W-PL-PR)*(n-1))));1226      mk.style.visibility = "visible";1227      mline.setAttribute("x1",X(i)); mline.setAttribute("x2",X(i));1228      tip.classList.add("show");1229      tip.innerHTML = `${esc(ts[i]?ts[i].t:"")} — ` + sers.map((s,si)=> !on[si] ? "" :1230        `<span style="margin-left:8px">${esc(s.label)} : <b>${s.points[i]?fmtN(s.points[i].v):"—"}</b></span>`).join("");1231    };1232    svg.addEventListener("mousemove", e => move(e.clientX));1233    svg.addEventListener("touchstart", e => move(e.touches[0].clientX), {passive:true});1234    svg.addEventListener("touchmove", e => move(e.touches[0].clientX), {passive:true});1235    svg.addEventListener("mouseleave", () => { mk.style.visibility="hidden"; tip.classList.remove("show"); });1236    host.querySelectorAll(".st-legend button").forEach(b => b.onclick = () => {1237      const i = Number(b.dataset.i);1238      on[i] = !on[i];1239      if(!on.some(Boolean)) on[i] = true;1240      render();1241    });1242  };1243  render();1244}12451246/* ---- barres empilées (composition dans le temps, nuances d'accent) ---- */1247function stStacked(host, sp){1248  const keys = sp.keys || [], pts = sp.points || [];1249  if(!keys.length || !pts.length || !pts.some(p=>(p.values||[]).some(v=>v>0))){1250    host.innerHTML = stEmpty(sp.title); return;1251  }1252  const W=720, H=260, PL=54, PR=10, PT=14, PB=26;1253  const totals = pts.map(p=>(p.values||[]).reduce((s,v)=>s+(v||0),0));1254  const max = Math.max(...totals, 1);1255  const n = pts.length, bw = (W-PL-PR)/n;1256  let grid = "";1257  for(let g=0; g<5; g++){1258    const y = PT + (H-PT-PB)*g/4, v = max*(4-g)/4;1259    grid += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>1260      <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtI(v)}</text>`;1261  }1262  const xlab = [0, Math.floor(n/2), n-1].map(i =>1263    `<text x="${PL+i*bw+bw/2}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`).join("");1264  let bars = "";1265  pts.forEach((p,i)=>{1266    let yTop = H-PB;1267    const tt = `${p.t} — total ${fmtN(totals[i])}${sp.unit?` ${sp.unit}`:""} · ` +1268      keys.map((k,ki)=>`${k} : ${fmtN((p.values||[])[ki]||0)}`).join(" · ");1269    (p.values||[]).forEach((v,ki)=>{1270      const h = (H-PT-PB)*(v||0)/max;1271      if(h>0){1272        yTop -= h;1273        bars += `<rect x="${(PL+i*bw+bw*0.1).toFixed(1)}" y="${yTop.toFixed(1)}" width="${(bw*0.8).toFixed(1)}"1274          height="${h.toFixed(1)}" fill="var(--accent)" fill-opacity="${ST_SHADES[ki%8]}"1275          stroke="var(--ink)" stroke-width=".6"><title>${esc(tt)}</title></rect>`;1276      }1277    });1278  });1279  host.innerHTML = `<figure class="gk-card st-fig">1280    <figcaption><b>${esc(sp.title)}</b>1281      <span class="st-legend">${keys.map((k,i)=>1282        `<button type="button" disabled style="cursor:default"><span class="sw fill" style="opacity:${ST_SHADES[i%8]}"></span>${esc(k)}</button>`).join("")}</span></figcaption>1283    <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(sp.title)}">${grid}${xlab}${bars}</svg></figure>`;1284}12851286/* ---- heatmap horaire 7 jours × 24 h (dow 0 = lundi) ---- */1287function stHourly(host, h){1288  const cells = h.cells || [];1289  if(!cells.length){ host.innerHTML = stEmpty(h.title); return; }1290  const grid = Array.from({length:7}, ()=>Array(24).fill(0));1291  cells.forEach(c => { if(c.dow>=0 && c.dow<7 && c.hour>=0 && c.hour<24) grid[c.dow][c.hour] = c.value; });1292  const max = Math.max(...cells.map(c=>c.value), 1);1293  const DAYS = ["Lun","Mar","Mer","Jeu","Ven","Sam","Dim"];1294  const CW=17, CH=17, OX=38, OY=16;1295  let rects = "";1296  for(let d=0; d<7; d++){1297    rects += `<text x="${OX-6}" y="${OY+d*CH+12}" text-anchor="end" font-size="9.5" fill="var(--ink-3)" font-family="var(--font-mono)">${DAYS[d]}</text>`;1298    for(let hr=0; hr<24; hr++){1299      const v = grid[d][hr];1300      rects += `<rect x="${OX+hr*CW}" y="${OY+d*CH}" width="${CW-2}" height="${CH-2}" rx="2.5"1301        fill="${v?"var(--accent)":"rgba(20,24,20,0.07)"}" fill-opacity="${v?(.22+.78*v/max).toFixed(3):1}"1302        stroke="rgba(20,24,20,0.15)" stroke-width=".5">1303        <title>${DAYS[d]} ${hr} h — ${fmtN(v)} événement${v>1?"s":""}</title></rect>`;1304    }1305  }1306  for(let hr=0; hr<24; hr+=3){1307    rects += `<text x="${OX+hr*CW+(CW-2)/2}" y="${OY-5}" text-anchor="middle" font-size="9" fill="var(--ink-3)" font-family="var(--font-mono)">${hr} h</text>`;1308  }1309  host.innerHTML = `<figure class="gk-card st-fig">1310    <figcaption><b>${esc(h.title)}</b></figcaption>1311    <div class="tbl-wrap" style="margin-top:12px">1312      <svg viewBox="0 0 ${OX+24*CW} ${OY+7*CH}" style="min-width:480px" role="img" aria-label="${esc(h.title)}">${rects}</svg>1313    </div></figure>`;1314}13151316/* ---- anneau ---- */1317function stDonut(host, title, items){1318  const rows = (items||[]).filter(r=>r.value>0).slice(0,8);1319  const total = rows.reduce((s,r)=>s+r.value,0);1320  if(!total){ host.innerHTML = stEmpty(title); return; }1321  const R = 74, C = 2*Math.PI*R;1322  let acc = 0;1323  const segs = rows.map((r,i)=>{1324    const frac = r.value/total, off = acc; acc += frac;1325    return `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)"1326      stroke-opacity="${ST_SHADES[i%8]}" stroke-width="30"1327      stroke-dasharray="${(frac*C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-off*C).toFixed(2)}"1328      transform="rotate(-90 100 100)"><title>${esc(r.label)} — ${fmtN(r.value)} (${(100*r.value/total).toFixed(1)} %)</title></circle>`;1329  }).join("");1330  host.innerHTML = `<figure class="gk-card st-fig">1331    <figcaption><b>${esc(title)}</b></figcaption>1332    <div class="st-donut-wrap">1333      <svg viewBox="0 0 200 200" role="img" aria-label="${esc(title)}">${segs}1334        <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" stroke-width="1" opacity=".5"/></svg>1335      <ul>${rows.map((r,i)=>`<li><span class="sw" style="opacity:${ST_SHADES[i%8]}"></span>1336        <span class="lb">${esc(r.label)}</span><b>${(100*r.value/total).toFixed(1).replace(".",",")} %</b></li>`).join("")}</ul>1337    </div></figure>`;1338}13391340/* ---- heatmap calendrier (26 semaines) ---- */1341function stHeat(host, hm){1342  const cells = hm.cells || [];1343  if(!cells.length){ host.innerHTML = stEmpty(hm.title); return; }1344  const max = Math.max(...cells.map(c=>c.value), 1);1345  const weeks = Math.ceil(cells.length/7);1346  let rects = "";1347  cells.forEach((c,i)=>{1348    const w = Math.floor(i/7), d = i%7;1349    rects += `<rect x="${w*14}" y="${d*14}" width="12" height="12" rx="2.5"1350      fill="${c.value?"var(--accent)":"rgba(20,24,20,0.07)"}"1351      fill-opacity="${c.value?(.25+.75*c.value/max).toFixed(3):1}"1352      stroke="rgba(20,24,20,0.15)" stroke-width=".5"><title>${esc(c.date)} — ${fmtN(c.value)} événement${c.value>1?"s":""}</title></rect>`;1353  });1354  host.innerHTML = `<figure class="gk-card st-fig">1355    <figcaption><b>${esc(hm.title)}</b> <span class="klabel">26 prochaines semaines · ${esc(cells[0].date)} → ${esc(cells[cells.length-1].date)}</span></figcaption>1356    <div class="tbl-wrap" style="margin-top:12px">1357      <svg viewBox="0 0 ${weeks*14} ${7*14}" style="min-width:480px" role="img" aria-label="${esc(hm.title)}">${rects}</svg>1358    </div></figure>`;1359}13601361/* ---- tableau : tri par colonne, recherche, pagination 25 ---- */1362function stTable(host, spec){1363  const stt = { q:"", col:null, dir:1, page:0 };1364  const num = x => typeof x === "number" ? x1365    : parseFloat(String(x).replace(/[^\d.,-]/g,"").replace(",","."));1366  const render = () => {1367    let rows = spec.rows || [];1368    if(stt.q){1369      const q = stt.q.toLowerCase();1370      rows = rows.filter(r => r.some(c => String(c).toLowerCase().includes(q)));1371    }1372    if(stt.col !== null){1373      rows = [...rows].sort((a,b)=>{1374        const nx = num(a[stt.col]), ny = num(b[stt.col]);1375        if(!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx-ny)*stt.dir;1376        return String(a[stt.col]).localeCompare(String(b[stt.col]),"fr")*stt.dir;1377      });1378    }1379    const pages = Math.max(1, Math.ceil(rows.length/25));1380    const cur = Math.min(stt.page, pages-1);1381    host.innerHTML = `<section class="gk-card st-fig">1382      <div class="st-tbl-head"><b style="font-family:var(--font-display);font-size:15px">${esc(spec.title)}</b>1383        <input class="input" placeholder="Rechercher…" value="${esc(stt.q)}" aria-label="Rechercher dans ${esc(spec.title)}"></div>1384      <div class="tbl-wrap" style="margin-top:10px"><table class="st-table">1385        <thead><tr>${spec.columns.map((c,i)=>1386          `<th data-c="${i}" aria-sort="${stt.col===i?(stt.dir===1?"ascending":"descending"):"none"}">${esc(c)} ${stt.col===i?(stt.dir===1?"▲":"▼"):"↕"}</th>`).join("")}</tr></thead>1387        <tbody>${rows.slice(cur*25,(cur+1)*25).map(r=>1388          `<tr>${r.map(c=>`<td>${typeof c==="number"?fmtN(c):esc(c)}</td>`).join("")}</tr>`).join("")}</tbody>1389      </table></div>1390      <div class="st-tbl-foot"><span class="klabel">${fmtI(rows.length)} lignes</span>1391        <span class="pg">1392          <button type="button" class="btn btn-ghost" data-pg="-1" ${cur===0?"disabled":""}>←</button>1393          <span class="chip">${cur+1} / ${pages}</span>1394          <button type="button" class="btn btn-ghost" data-pg="1" ${cur>=pages-1?"disabled":""}>→</button>1395        </span></div></section>`;1396    const inp = host.querySelector("input");1397    let t;1398    inp.addEventListener("input", e => {1399      clearTimeout(t);1400      t = setTimeout(()=>{ stt.q = e.target.value; stt.page = 0; render();1401        host.querySelector("input").focus(); }, 250);1402    });1403    host.querySelectorAll("th").forEach(th => th.onclick = () => {1404      const c = Number(th.dataset.c);1405      stt.dir = stt.col === c && stt.dir === 1 ? -1 : 1;1406      stt.col = c; render();1407    });1408    host.querySelectorAll("[data-pg]").forEach(b => b.onclick = () => {1409      stt.page = cur + Number(b.dataset.pg); render();1410    });1411  };1412  render();1413}14141415/* ---- rendu global ---- */1416function stRender(){1417  const d = ST.data;1418  if(!d) return;1419  const upd = new Date(d.updated);1420  $("st-fresh").innerHTML = `<span class="klabel">Mis à jour le1421      ${upd.toLocaleString("fr-CA",{dateStyle:"medium",timeStyle:"short"})}1422      · période : ${esc((d.period||{}).label||"")}</span>1423    <button type="button" class="btn btn-ghost" id="st-refresh">↻ Rafraîchir</button>`;1424  $("st-refresh").onclick = stFetch;14251426  $("st-kpis").innerHTML = (d.kpis||[]).map(stKpi).join("") || stEmpty("Indicateurs");1427  $("st-gauges").innerHTML = (d.gauges||[]).map(stGauge).join("");14281429  const mount = (parent) => { const el = document.createElement("div"); parent.appendChild(el); return el; };1430  $("st-series").innerHTML = "";1431  (d.series||[]).forEach(s => {1432    const el = mount($("st-series"));1433    if(s.kind === "bar") stVBar(el, s.title, (s.points||[]).map(p=>({label:p.t,value:p.v})), s.unit);1434    else stLine(el, s);1435  });14361437  $("st-multi").innerHTML = "";1438  (d.multiseries||[]).forEach(ms => stMulti(mount($("st-multi")), ms));1439  (d.stacked||[]).forEach(sp => stStacked(mount($("st-multi")), sp));14401441  $("st-breaks").innerHTML = "";1442  (d.breakdowns||[]).filter(b=>b.id!=="sources").forEach(b => {1443    const el = mount($("st-breaks"));1444    if(b.kind === "donut") stDonut(el, b.title, b.items);1445    else stBars(el, b.title, b.items);1446  });1447  (d.distributions||[]).forEach(di =>1448    stVBar(mount($("st-breaks")), di.title, di.bins, di.unit));14491450  $("st-geosrc").innerHTML = "";1451  if(d.geo) stBars(mount($("st-geosrc")), d.geo.title || "Par région", d.geo.items);1452  const src = (d.breakdowns||[]).find(b=>b.id==="sources");1453  if(src) stBars(mount($("st-geosrc")), src.title, src.items);14541455  $("st-heat").innerHTML = "";1456  if(d.heatmap) stHeat(mount($("st-heat")), d.heatmap);14571458  $("st-hourly").innerHTML = "";1459  if(d.hourly) stHourly(mount($("st-hourly")), d.hourly);14601461  $("st-tables").innerHTML = "";1462  (d.tables||[]).forEach(t => stTable(mount($("st-tables")), t));14631464  const recs = d.records || [];1465  $("st-records-wrap").classList.toggle("hidden", !recs.length);1466  $("st-records").innerHTML = recs.map(r => `<article class="gk-card st-record">1467    <span class="lb">${esc(r.label)}</span>1468    <span class="rv"><b>${esc(r.value)}</b>${r.date?`<span class="klabel">${esc(r.date)}</span>`:""}</span></article>`).join("");1469}14701471/* ---- boutons PDF : rapport complet + menu des 4 autres rapports ---- */1472function stPdf(mode, btn){1473  const original = btn.textContent;1474  btn.disabled = true; btn.textContent = "Génération…";1475  const a = document.createElement("a");1476  a.href = `/api/stats/report?${stParams(mode)}`;1477  a.download = "";1478  document.body.appendChild(a); a.click(); a.remove();1479  setTimeout(()=>{ btn.disabled = false; btn.textContent = original; }, 2500);1480}14811482function stPdfMenu(open){1483  const drop = $("st-pdf-drop"), more = $("st-pdf-more");1484  const on = open === undefined ? drop.classList.contains("hidden") : open;1485  drop.classList.toggle("hidden", !on);1486  more.setAttribute("aria-expanded", String(on));1487}1488$("st-pdf-more").onclick = e => { e.stopPropagation(); stPdfMenu(); };1489$("st-pdf-drop").querySelectorAll("[data-mode]").forEach(b => b.onclick = e => {1490  stPdfMenu(false);1491  stPdf(b.dataset.mode, $("st-pdf-more"));1492});1493document.addEventListener("click", e => {1494  if(!e.target.closest(".st-pdf-menu")) stPdfMenu(false);1495});14961497function showStats(){1498  document.title = "Statistiques — Sorti-Ka | Un service Groupe KA";1499  show("view-stats");1500  window.scrollTo({top:0});1501  if(!ST.data) stFetch();1502}15031504$("st-pdf-full").onclick = e => stPdf("complet", e.currentTarget);1505stRenderPeriods();15061507/* ==================== v3 : constructeur de rapports personnalisés ==========1508   Compose un PDF bloc par bloc : choix des données (catalogue du dashboard),1509   du rendu par bloc (courbe/aire/barres/anneau/heatmap/tableau…), de l'ordre,1510   et modèles sauvegardés (localStorage, propre au site = origine). */1511const SB = { cat:null, sel:[], title:"", busy:false, err:"" };1512const SB_TPL_KEY = "ka-stats-rapports";1513const SB_RENDERS = { line:"Courbe", area:"Aire", bar:"Barres verticales",1514  bars:"Barres horizontales", donut:"Anneau", lines:"Multi-courbes",1515  stacked:"Barres empilées", histogram:"Histogramme", heatmap:"Heatmap",1516  cards:"Cartes", gauges:"Jauges", table:"Tableau" };1517const SB_SECTIONS = { kpis:"Indicateurs", gauges:"Jauges", series:"Évolution",1518  multiseries:"Multi-courbes", stacked:"Compositions", breakdowns:"Répartitions",1519  distributions:"Distributions", geo:"Géographie", heatmap:"Calendrier",1520  hourly:"Activité horaire", tables:"Tableaux", records:"Records" };15211522function sbTemplates(){1523  try{ return JSON.parse(localStorage.getItem(SB_TPL_KEY) || "[]"); }1524  catch(e){ return []; }1525}1526function sbSaveTemplates(t){ localStorage.setItem(SB_TPL_KEY, JSON.stringify(t)); }15271528async function sbOpen(){1529  let ov = $("st-builder");1530  if(!ov){1531    ov = document.createElement("div");1532    ov.id = "st-builder";1533    ov.className = "stb-overlay hidden";1534    ov.onclick = e => { if(e.target === ov) sbClose(); };1535    document.body.appendChild(ov);1536  }1537  ov.classList.remove("hidden");1538  document.body.style.overflow = "hidden";1539  SB.err = "";1540  if(!SB.cat){1541    ov.innerHTML = `<div class="stb-panel"><div class="stb-head">1542      <h2>Rapport personnalisé</h2></div>1543      <div class="stb-col"><p class="klabel">Chargement du catalogue…</p></div></div>`;1544    try{1545      SB.cat = (await (await fetch(`/api/stats/catalog?${stParams()}`)).json()).blocks || [];1546    }catch(e){1547      ov.innerHTML = `<div class="stb-panel"><div class="stb-head"><h2>Rapport personnalisé</h2>1548        <button type="button" class="btn btn-ghost" onclick="sbClose()">✕ Fermer</button></div>1549        <div class="stb-col"><p class="stb-err">Catalogue indisponible — réessayez plus tard.</p></div></div>`;1550      return;1551    }1552  }1553  sbRender();1554}1555function sbClose(){1556  const ov = $("st-builder");1557  if(ov) ov.classList.add("hidden");1558  document.body.style.overflow = "";1559}15601561function sbAdd(key){1562  const b = SB.cat.find(x => x.key === key);1563  if(!b || SB.sel.some(s => s.key === key && s.render === b.default_render)) return;1564  SB.sel.push({ key, render: b.default_render });1565  sbRender();1566}1567function sbDel(i){ SB.sel.splice(i, 1); sbRender(); }1568function sbMove(i, d){1569  const j = i + d;1570  if(j < 0 || j >= SB.sel.length) return;1571  [SB.sel[i], SB.sel[j]] = [SB.sel[j], SB.sel[i]];1572  sbRender();1573}1574function sbAll(){1575  SB.sel = SB.cat.map(b => ({ key: b.key, render: b.default_render }));1576  sbRender();1577}15781579function sbRender(){1580  const ov = $("st-builder");1581  const groups = {};1582  SB.cat.forEach(b => (groups[b.section] = groups[b.section] || []).push(b));1583  const selKeys = new Set(SB.sel.map(s => s.key));1584  const perLabel = (ST.from && ST.to) ? `${ST.from} → ${ST.to}`1585    : (ST_PERIODS.find(p => p[0] === ST.period) || ["","30 jours"])[1];1586  const tpls = sbTemplates();15871588  ov.innerHTML = `<div class="stb-panel">1589    <div class="stb-head">1590      <h2>Rapport personnalisé <span class="klabel">· période : ${esc(perLabel)}</span></h2>1591      <button type="button" class="btn btn-ghost" id="stb-close">✕ Fermer</button>1592    </div>1593    <div class="stb-body">1594      <div class="stb-col">1595        <h3>Blocs disponibles (${SB.cat.length})</h3>1596        <p class="klabel" style="margin:0 0 10px">Cliquez « + » pour ajouter un bloc au rapport.1597          Un même bloc peut être ajouté sous plusieurs rendus.</p>1598        ${Object.entries(groups).map(([sec, bs]) => `1599          <p class="stb-group">${esc(SB_SECTIONS[sec] || sec)}</p>1600          ${bs.map(b => `<div class="stb-av${selKeys.has(b.key) ? " stb-in" : ""}">1601            <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"1602              title="${esc(b.title)}">${esc(b.title)}</span>1603            <button type="button" class="btn btn-ghost" data-add="${esc(b.key)}"1604              aria-label="Ajouter ${esc(b.title)}">+</button></div>`).join("")}`).join("")}1605      </div>1606      <div class="stb-col">1607        <h3>Composition du rapport (${SB.sel.length})</h3>1608        <label class="klabel" for="stb-title">Titre du rapport</label>1609        <input class="input" id="stb-title" style="width:100%;margin:4px 0 12px"1610          maxlength="80" placeholder="Ex. : Revue mensuelle" value="${esc(SB.title)}">1611        ${SB.sel.length ? SB.sel.map((s, i) => {1612          const b = SB.cat.find(x => x.key === s.key) || { title: s.key, renders: [s.render] };1613          return `<div class="stb-item">1614            <button type="button" class="mv" data-up="${i}" ${i === 0 ? "disabled" : ""} aria-label="Monter">▲</button>1615            <button type="button" class="mv" data-dn="${i}" ${i === SB.sel.length - 1 ? "disabled" : ""} aria-label="Descendre">▼</button>1616            <span class="t" title="${esc(b.title)}"><b>${i + 1}.</b> ${esc(b.title)}</span>1617            ${b.renders.length > 1 ? `<select data-rd="${i}" aria-label="Rendu">1618              ${b.renders.map(r => `<option value="${r}"${r === s.render ? " selected" : ""}>${SB_RENDERS[r] || r}</option>`).join("")}1619            </select>` : `<span class="klabel">${SB_RENDERS[s.render] || s.render}</span>`}1620            <button type="button" class="mv" data-del="${i}" aria-label="Retirer">✕</button>1621          </div>`;1622        }).join("") : `<div class="stb-empty">Aucun bloc — ajoutez des blocs depuis la colonne de gauche,1623          ou chargez un modèle ci-dessous.</div>`}1624        <p style="display:flex;gap:8px;margin:10px 0 0">1625          <button type="button" class="btn btn-ghost" id="stb-all">Tout ajouter</button>1626          <button type="button" class="btn btn-ghost" id="stb-clear" ${SB.sel.length ? "" : "disabled"}>Vider</button>1627        </p>1628        ${SB.err ? `<p class="stb-err">${esc(SB.err)}</p>` : ""}1629      </div>1630    </div>1631    <div class="stb-foot">1632      <span class="stb-tpl">1633        <select class="input" id="stb-tpl" aria-label="Modèles sauvegardés" style="max-width:210px">1634          <option value="">Modèles (${tpls.length})…</option>1635          ${tpls.map((t, i) => `<option value="${i}">${esc(t.name)}</option>`).join("")}1636        </select>1637        <button type="button" class="btn btn-ghost" id="stb-save" ${SB.sel.length ? "" : "disabled"}>💾 Sauvegarder</button>1638        <button type="button" class="btn btn-ghost" id="stb-tpl-del" ${tpls.length ? "" : "disabled"}>🗑 Supprimer</button>1639      </span>1640      <button type="button" class="btn btn-primary" id="stb-go" ${SB.sel.length && !SB.busy ? "" : "disabled"}>1641        ${SB.busy ? "Génération…" : "⬇ Générer le PDF"}</button>1642    </div>1643  </div>`;16441645  $("stb-close").onclick = sbClose;1646  $("stb-all").onclick = sbAll;1647  $("stb-clear").onclick = () => { SB.sel = []; sbRender(); };1648  $("stb-title").oninput = e => { SB.title = e.target.value; };1649  ov.querySelectorAll("[data-add]").forEach(b => b.onclick = () => sbAdd(b.dataset.add));1650  ov.querySelectorAll("[data-up]").forEach(b => b.onclick = () => sbMove(+b.dataset.up, -1));1651  ov.querySelectorAll("[data-dn]").forEach(b => b.onclick = () => sbMove(+b.dataset.dn, 1));1652  ov.querySelectorAll("[data-del]").forEach(b => b.onclick = () => sbDel(+b.dataset.del));1653  ov.querySelectorAll("[data-rd]").forEach(sl => sl.onchange = () => {1654    SB.sel[+sl.dataset.rd].render = sl.value;1655  });1656  $("stb-save").onclick = () => {1657    const name = prompt("Nom du modèle :", SB.title || "Mon rapport");1658    if(!name) return;1659    const tpl = sbTemplates().filter(t => t.name !== name);1660    tpl.push({ name, title: SB.title, blocks: SB.sel.map(s => ({...s})) });1661    sbSaveTemplates(tpl);1662    sbRender();1663  };1664  $("stb-tpl").onchange = e => {1665    const t = sbTemplates()[+e.target.value];1666    if(!t) return;1667    SB.title = t.title || t.name;1668    SB.sel = (t.blocks || []).filter(s => SB.cat.some(b => b.key === s.key)).map(s => ({...s}));1669    sbRender();1670  };1671  $("stb-tpl-del").onclick = () => {1672    const sel = $("stb-tpl");1673    const t = sbTemplates()[+sel.value];1674    if(!t || !confirm(`Supprimer le modèle « ${t.name} » ?`)) return;1675    sbSaveTemplates(sbTemplates().filter(x => x.name !== t.name));1676    sbRender();1677  };1678  $("stb-go").onclick = sbGenerate;1679}16801681async function sbGenerate(){1682  if(SB.busy || !SB.sel.length) return;1683  SB.busy = true; SB.err = ""; sbRender();1684  try{1685    const body = { title: SB.title, period: ST.period, blocks: SB.sel };1686    if(ST.from && ST.to){ body.from = ST.from; body.to = ST.to; }1687    const r = await fetch("/api/stats/report/custom", {1688      method: "POST", headers: { "Content-Type": "application/json" },1689      body: JSON.stringify(body) });1690    if(!r.ok) throw new Error(`HTTP ${r.status}`);1691    const blob = await r.blob();1692    const cd = r.headers.get("Content-Disposition") || "";1693    const m = cd.match(/filename="?([^";]+)/);1694    const a = document.createElement("a");1695    a.href = URL.createObjectURL(blob);1696    a.download = m ? m[1] : "rapport-personnalise.pdf";1697    document.body.appendChild(a); a.click(); a.remove();1698    setTimeout(() => URL.revokeObjectURL(a.href), 4000);1699  }catch(e){1700    SB.err = "La génération a échoué — réessayez.";1701  }1702  SB.busy = false; sbRender();1703}17041705$("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); };17061707/* ---------- favoris ♥ « Mon univers Ka » ----------1708   Le hub Groupe KA (groupe-ka.com) est le magasin central des favoris du1709   groupe : AUCUN stockage navigateur. Lecture/écriture via l'API locale1710   /api/favorites, qui relaie au hub (session KA ID requise). */1711const FAV = {ready:false, connected:false, ids:new Set(), items:[], meta:{}};1712const FAV_HEART = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;17131714/* événement -> item de favori poussé au hub (titre, lieu · date, prix, image) */1715function favItem(ev){1716  let price = "";1717  if(ev.is_free === true) price = "Gratuit";1718  else if(ev.price_min != null) price = `Dès ${ev.price_min.toFixed(2).replace(".",",")} $`;1719  return {1720    item_id: ev.uid,1721    title: ev.title || "Événement",1722    subtitle: [ev.venue || ev.city, dateBadge(ev)].filter(Boolean).join(" · "),1723    price_label: price,1724    image_url: ev.image || "",1725    url: `https://www.sorti-ka.com/evenement/${encodeURIComponent(ev.uid)}`,1726  };1727}17281729/* cœur ♥ — cartes (par défaut) et fiche événement (big) */1730function favBtn(ev, big=false){1731  FAV.meta[ev.uid] = favItem(ev);1732  const on = FAV.ids.has(ev.uid);1733  const label = on ? "Retirer de mes favoris (Mon univers Ka)"1734                   : "Ajouter à mes favoris (Mon univers Ka)";1735  return `<button type="button" class="fav-btn${on?" on":""}${big?" big":""}"1736    data-fav="${esc(ev.uid)}" aria-pressed="${on}" aria-label="${label}"1737    title="${label}">${FAV_HEART}</button>`;1738}17391740/* repeint tous les cœurs affichés selon l'état courant */1741function favPaint(){1742  document.querySelectorAll(".fav-btn[data-fav]").forEach(b => {1743    const on = FAV.ids.has(b.dataset.fav);1744    b.classList.toggle("on", on);1745    b.setAttribute("aria-pressed", on);1746    const label = on ? "Retirer de mes favoris (Mon univers Ka)"1747                     : "Ajouter à mes favoris (Mon univers Ka)";1748    b.setAttribute("aria-label", label); b.title = label;1749  });1750}17511752/* lit la liste au hub (via l'API locale) — 401 = pas connecté, cœurs vides */1753async function favLoad(){1754  try{1755    const r = await fetch("/api/favorites");1756    if(r.status === 401){ FAV.connected = false; return; }1757    if(!r.ok) return;1758    const d = await r.json();1759    FAV.connected = true;1760    FAV.items = d.items || [];1761    FAV.ids = new Set(d.ids || []);1762    favPaint();1763  }catch(e){}1764  finally{ FAV.ready = true; }1765}17661767/* toggle optimiste — le hub confirme (sinon on rétablit) */1768async function favToggle(uid){1769  if(!FAV.connected){ location.href = "/api/auth/ka/login"; return; }1770  const on = !FAV.ids.has(uid);1771  const item = FAV.meta[uid] || FAV.items.find(i => i.item_id === uid) || {item_id: uid};1772  const apply = v => {1773    if(v){ FAV.ids.add(uid); if(!FAV.items.some(i=>i.item_id===uid)) FAV.items.push(item); }1774    else { FAV.ids.delete(uid); FAV.items = FAV.items.filter(i => i.item_id !== uid); }1775    favPaint();1776    if(location.pathname === "/favoris") renderFavs();1777  };1778  apply(on);1779  try{1780    const r = await fetch("/api/favorites/toggle", {method:"POST",1781      headers:{"Content-Type":"application/json"},1782      body: JSON.stringify({on, item})});1783    if(!r.ok) throw new Error(r.status);1784  }catch(e){ apply(!on); }1785}17861787/* clic sur un cœur — en phase capture : la carte entière est un lien */1788document.addEventListener("click", e => {1789  const b = e.target.closest(".fav-btn[data-fav]");1790  if(!b) return;1791  e.preventDefault(); e.stopPropagation();1792  favToggle(b.dataset.fav);1793}, true);17941795/* carte d'un favori (données du hub — l'événement peut avoir quitté l'index) */1796function favCard(it){1797  FAV.meta[it.item_id] = it;1798  const img = it.image_url1799    ? `<img src="${esc(it.image_url)}" alt="" loading="lazy" onerror="this.remove()">` : "";1800  const local = (it.url || "").replace(/^https?:\/\/[^/]*sorti-ka\.com/i, "")1801    || `/evenement/${encodeURIComponent(it.item_id)}`;1802  return `<a class="card gk-card gk-hover" data-nav href="${esc(local)}">1803    <div class="card-img">${img}1804      <button type="button" class="fav-btn on" data-fav="${esc(it.item_id)}" aria-pressed="true"1805        aria-label="Retirer de mes favoris (Mon univers Ka)"1806        title="Retirer de mes favoris (Mon univers Ka)">${FAV_HEART}</button>1807    </div>1808    <div class="card-body">1809      <div class="card-top"><h3>${esc(it.title)}</h3>${it.price_label?`<span class="badge-price">${esc(it.price_label)}</span>`:""}</div>1810      ${it.subtitle?`<div class="where">${esc(it.subtitle)}</div>`:""}1811      <div class="src"><span>Mon univers Ka</span><span class="go">Fiche →</span></div>1812    </div>1813  </a>`;1814}18151816function renderFavs(){1817  if(!FAV.connected){1818    $("fav-count").innerHTML = "Connectez-vous pour retrouver vos favoris";1819    $("fav-grid").innerHTML = `<div class="empty fav-empty">1820      <b>Vos favoris vous suivent partout dans l'univers Ka</b>1821      Connectez-vous avec votre KA ID pour ajouter des sorties à vos favoris —1822      ils sont synchronisés sur toutes les plateformes du Groupe KA.<br>1823      <a class="btn-ka" href="/api/auth/ka/login"><span class="kdot">·Ka</span> Se connecter avec KA ID</a></div>`;1824    return;1825  }1826  const items = FAV.items;1827  $("fav-count").innerHTML = items.length1828    ? `<b>${items.length.toLocaleString("fr-CA")}</b>&nbsp;favori${items.length>1?"s":""} sur Sorti·Ka`1829    : "Aucun favori pour l'instant";1830  $("fav-grid").innerHTML = items.map(favCard).join("") ||1831    `<div class="empty fav-empty"><b>Aucun favori pour l'instant</b>1832     Touchez le cœur ♥ d'un événement pour le garder ici — et le retrouver1833     sur toutes les plateformes du Groupe KA.</div>`;1834  bindNav($("fav-grid"));1835}18361837async function showFavoris(){1838  document.title = "Mes favoris — Sorti-Ka | Mon univers Ka";1839  show("view-favoris");1840  window.scrollTo({top:0});1841  await favLoad();      // le hub est la source de vérité — relire à l'ouverture1842  renderFavs();1843}18441845/* ---------- vues ---------- */1846const VIEWS = ["view-list","view-detail","view-contact","view-stats","view-favoris"];1847function show(id){1848  VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id));1849}18501851function showList(){1852  document.title = "Sorti-Ka — Un service Groupe KA";1853  show("view-list");1854}18551856function showContact(){1857  document.title = "Contact — Sorti-Ka | Un service Groupe KA";1858  show("view-contact");1859  window.scrollTo({top:0});1860}18611862/* ---------- routeur ---------- */1863/* slug ASCII identique à sortika/seo.py (pages /region/… et /categorie/…) */1864function kaSlug(s){1865  return s.normalize("NFKD").replace(/[̀-ͯ]/g,"")1866    .toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"");1867}1868let allRegions = [];1869function route(){1870  const m = location.pathname.match(/^\/evenement\/(.+)$/);1871  const r = location.pathname.match(/^\/region\/([^/]+)$/);1872  const c = location.pathname.match(/^\/categorie\/([^/]+)$/);1873  if(m) showDetail(decodeURIComponent(m[1]));1874  else if(location.pathname === "/contact") showContact();1875  else if(location.pathname === "/favoris") showFavoris();1876  else if(location.pathname === "/stats") showStats();1877  else if(r){1878    // page SSR /region/{slug} : la SPA ouvre la recherche filtrée sur la région1879    const name = allRegions.find(x => kaSlug(x) === decodeURIComponent(r[1]));1880    if(name){ state.region = name; $("region").value = name; }1881    showList(); load();1882  }1883  else if(c){1884    // page SSR /categorie/{cat} : recherche filtrée sur la catégorie1885    const cat = decodeURIComponent(c[1]);1886    state.category = cat;1887    document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===cat));1888    showList(); load();1889  }1890  else showList();1891}1892function go(path){1893  history.pushState({}, "", path);1894  route();1895}1896function bindNav(root){1897  (root||document).querySelectorAll("a[data-nav]").forEach(a => a.onclick = e => {1898    if(e.metaKey||e.ctrlKey||e.shiftKey) return;1899    e.preventDefault(); go(a.getAttribute("href"));1900  });1901}1902window.addEventListener("popstate", route);1903$("back").onclick = () => go("/");19041905/* ---------- KA Nav v2 : menu mobile plein écran (2026-08-25) ---------- */1906(function(){1907  const btn = document.getElementById("ka-mnav-btn");1908  const panel = document.getElementById("ka-mnav");1909  if(!btn || !panel) return;1910  function setMenu(open){1911    panel.classList.toggle("open", open);1912    btn.setAttribute("aria-expanded", open ? "true" : "false");1913    document.documentElement.classList.toggle("ka-menu-locked", open);1914  }1915  btn.addEventListener("click", () => setMenu(!panel.classList.contains("open")));1916  panel.addEventListener("click", e => {1917    if(e.target.closest("a") || e.target.closest("[data-ka-mnav-close]")) setMenu(false);1918  });1919  document.addEventListener("keydown", e => { if(e.key === "Escape") setMenu(false); });1920})();19211922/* ---------- init ---------- */1923async function init(){1924  favLoad();   // favoris ♥ — repeint les cœurs dès la réponse du hub1925  const regions = await (await fetch("/api/regions")).json();1926  allRegions = regions;1927  $("region").insertAdjacentHTML("beforeend",1928    regions.map(r=>`<option>${esc(r)}</option>`).join(""));19291930  const cats = await (await fetch("/api/categories")).json();1931  $("cats").innerHTML =1932    `<span class="chip free" id="chip-free">✦ Gratuit</span>` +1933    cats.filter(c=>c!=="autre").map(c=>`<span class="chip" data-cat="${c}">${CAT_LABELS[c]||c}</span>`).join("");1934  $("chip-free").onclick = e => { state.free=!state.free; e.target.classList.toggle("on",state.free); load(); };1935  document.querySelectorAll(".chip[data-cat]").forEach(ch => ch.onclick = () => {1936    const c = ch.dataset.cat;1937    state.category = state.category===c ? "" : c;1938    document.querySelectorAll(".chip[data-cat]").forEach(x=>x.classList.toggle("on", x.dataset.cat===state.category));1939    load();1940  });19411942  try{1943    const s = await (await fetch("/api/stats")).json();1944    const nReg = Object.keys(s.by_region).filter(r=>r!=="Non rattachée").length;1945    $("stats").innerHTML = [1946      `<span class="stat-chip"><span class="pulse"></span><b>${s.upcoming.toLocaleString("fr-CA")}</b> événements à venir</span>`,1947      `<span class="stat-chip"><b>${s.free_upcoming.toLocaleString("fr-CA")}</b> gratuits</span>`,1948      `<span class="stat-chip"><b>${nReg}</b> régions</span>`,1949      `<span class="stat-chip"><b>${s.cities.toLocaleString("fr-CA")}</b> villes</span>`,1950    ].join("");1951  }catch(e){}19521953  try{1954    const reg = await (await fetch("/api/sources")).json();1955    $("src-grid").innerHTML = (reg.sources||[]).filter(s=>s.statut==="actif"||s.active_events>0).map(s=>`1956      <div class="src-card gk-card">1957        <h4>${esc(s.name)}</h4>1958        <div class="lic">${esc((s.acces_legal||"").split("—")[0])}</div>1959        <div class="n"><b>${(s.active_events||0).toLocaleString("fr-CA")}</b> événements actifs · ${esc((s.extraction||"").split("(")[0])}</div>1960      </div>`).join("");1961  }catch(e){}19621963  // --- KA ID (« Se connecter avec KA » — hub groupe-ka.com) ---1964  try{1965    const cfg = await (await fetch("/api/auth/config")).json();1966    if(cfg.ka){1967      const meRes = await fetch("/api/me");1968      if(meRes.ok){1969        const me = await meRes.json();1970        const pic = me.picture ? `<img src="${esc(me.picture)}" alt="" onerror="this.remove()">` : "";1971        $("auth").innerHTML = `<span class="user-chip">${pic}1972          <span><span class="uname">${esc(me.name||me.email||"Membre")}</span>1973          <span class="kaid">${esc(me.ka_id||"")}</span></span>1974          <button class="out" id="logout">Sortir</button></span>`;1975        $("logout").onclick = async () => {1976          await fetch("/api/auth/logout", {method:"POST"});1977          location.href = "/";1978        };1979      } else {1980        $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login">1981          <span class="kdot">·Ka</span> Se connecter avec KA</a>1982          <a class="ka-signup" href="https://www.groupe-ka.com/connexion"1983             target="_blank" rel="noopener">Créer un compte</a>`;1984      }1985    }1986  }catch(e){}19871988  let t; $("q").addEventListener("input", e=>{1989    clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);1990  });1991  $("region").onchange = e => { state.region = e.target.value; load(); };1992  $("period").onchange = e => { setPeriod(e.target.value); load(); };1993  $("more").onclick = () => { state.offset += state.limit; load(false); };1994  bindNav(document);19951996  await load();1997  route();1998}1999init();2000</script>2001  <script>window.KA_AGENT={site:"sorti-ka"}</script>2002  <script src="/ka-agent.js" defer></script>2003</body>2004</html>2005
Files · 87515769f245cd886fa929023e3ffb961ba1adc9
Menu

Theme