SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
20.9 KB · 420 lines javascript
Raw Blame History
1/*2 * QC Élection Forecast — Plateforme de prévision électorale du Québec 20263 * Auteur : Simon-Pierre Boucher4 * Contact : contact@spboucher.ai5 * https://www.qc-election.com6 */7/* Utilitaires partagés — design system « QC Signal » */8const API = "/api";910/* ---- thème (sombre par défaut) ---- */11const THEME = localStorage.getItem("qce-theme") || "dark";12document.documentElement.dataset.theme = THEME;13function toggleTheme() {14  localStorage.setItem("qce-theme", THEME === "dark" ? "light" : "dark");15  location.reload(); // recharge : les graphiques reprennent les couleurs du thème16}17const PARTY_ORDER = ["PQ", "PLQ", "CAQ", "PCQ", "QS", "AUT"]; // jamais utilisé pour l'affichage : tri dynamique par résultat18const FALLBACK_COLORS = { CAQ: "#0097cf", PLQ: "#e5484d", PQ: "#3f7ad6", QS: "#e06c1f", PCQ: "#7069d6", AUT: "#ad8226" };1920let META = null;2122/* ---- visuels des partis (logos + chefs) — fichiers locaux, source Wikimedia ---- */23const PARTY_IMG = {24  CAQ: { logo: "/assets/img/logo_caq.png", leaders: ["/assets/img/leader_caq.jpg"] },25  PLQ: { logo: "/assets/img/logo_plq.png", leaders: ["/assets/img/leader_plq.jpg"] },26  PQ:  { logo: "/assets/img/logo_pq.png",  leaders: ["/assets/img/leader_pq.jpg"] },27  QS:  { logo: "/assets/img/logo_qs.png",  leaders: ["/assets/img/leader_qs1.jpg", "/assets/img/leader_qs2.jpg"] },28  PCQ: { logo: "/assets/img/logo_pcq.png", leaders: ["/assets/img/leader_pcq.jpg"] },29};30function partyLogo(p) { return (PARTY_IMG[p] || {}).logo || null; }31function leaderPhotos(p) { return (PARTY_IMG[p] || {}).leaders || []; }32function avatarHTML(p, size = 40) {33  const photos = leaderPhotos(p);34  if (!photos.length) return "";35  return photos.map(src =>36    `<img class="avatar" src="${src}" alt="${partyLeader(p) || partyName(p)}"37      style="width:${size}px;height:${size}px;border-color:${partyColor(p)}"38      loading="lazy">`).join("");39}40function initialsAvatar(name, color, size = 30) {41  const init = (name || "?").split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase();42  return `<span class="avatar avatar-init" style="width:${size}px;height:${size}px;43    border-color:${color};color:${color};font-size:${Math.round(size * 0.36)}px">${init}</span>`;44}4546async function getJSON(url, opts) {47  const r = await fetch(url, opts);48  if (!r.ok) throw new Error(`${url}: ${r.status}`);49  return r.json();50}5152async function loadMeta() {53  if (!META) META = await getJSON(`${API}/meta`);54  return META;55}5657function partyColor(p) {58  return (META && META.parties[p] && META.parties[p].color) || FALLBACK_COLORS[p] || "#888";59}60function partyName(p) {61  return (META && META.parties[p] && META.parties[p].name) || p;62}63function partyLeader(p) {64  return (META && META.parties[p] && META.parties[p].leader) || "";65}66function fmtPct(x, d = 0) { return (x * 100).toFixed(d) + " %"; }67function fmtDate(iso) {68  return new Date(iso + (iso.length === 10 ? "T12:00:00" : "")).toLocaleDateString("fr-CA",69    { year: "numeric", month: "short", day: "numeric" });70}7172/* Tri neutre : par probabilité décroissante dans le run courant (aucun parti favorisé73   par un ordre arbitraire fixe). */74function sortParties(seats) {75  return Object.keys(seats).filter(p => p !== "AUT")76    .sort((a, b) => seats[b].prob_most - seats[a].prob_most || seats[b].mean - seats[a].mean);77}7879/* ============================================================================80   Grammaire des données — helpers d'affichage partagés (QC Signal)81   ========================================================================== */8283/* Indicateur de variation : flèche + valeur, JAMAIS vert/rouge (une hausse d'un84   parti n'est pas « positive » universellement). Couleur du parti ou neutre.85   d = variation (déjà dans l'unité affichée). */86function deltaHTML(d, { unit = " pt", digits = 1, color = null, thresh = 0.05, title = "" } = {}) {87  if (d == null || Number.isNaN(d)) return "";88  const flat = Math.abs(d) < thresh;89  const arr = flat ? "·" : d > 0 ? "▲" : "▼";90  const txt = flat ? "stable" : Math.abs(d).toFixed(digits) + unit;91  const st = color && !flat ? `style="color:${color}"` : "";92  return `<span class="delta" ${st} ${title ? `title="${title}"` : ""}>` +93    `<span class="arr" aria-hidden="true">${arr}</span>${txt}</span>`;94}9596/* Intervalle visuel  lo ───●─── hi  (dot = valeur centrale, mark = repère). */97function rangeHTML({ lo, hi, val, min = 0, max = 127, color = "#8a93a8", mark = null,98                     fmt = v => Math.round(v) } = {}) {99  const pc = v => Math.max(0, Math.min(100, (v - min) / (max - min) * 100));100  return `<div class="range" role="img" aria-label="intervalle ${fmt(lo)} à ${fmt(hi)}, valeur ${fmt(val)}">101    <span>${fmt(lo)}</span>102    <span class="rtrack">103      <span class="rfill" style="left:${pc(lo)}%;right:${100 - pc(hi)}%;background:${color}"></span>104      ${mark != null ? `<span class="rmark" style="left:${pc(mark)}%"></span>` : ""}105      <span class="rdot" style="left:${pc(val)}%;background:${color}"></span>106    </span>107    <span>${fmt(hi)}</span>108  </div>`;109}110111/* État d'erreur propre — jamais de « undefined » à l'écran. */112function emptyStateHTML(msg = "Données temporairement indisponibles.") {113  return `<div class="empty-note">${msg}</div>`;114}115116/* ============================================================================117   Hémicycle SVG — 127 sièges en demi-cercle, composant réutilisable.118   seats : { PARTI: { mean, p50?, p05, p95 } } ; meta : /api/meta.119   Retourne un élément .hemi prêt à insérer (hover : parti / sièges / intervalle).120   ========================================================================== */121function hemicycleSVG(seats, meta, { legend = true } = {}) {122  const total = (meta && meta.election && meta.election.total_seats) || 127;123  const majority = (meta && meta.election && meta.election.majority_seats) || Math.floor(total / 2) + 1;124125  /* -- répartition entière des sièges (p50 sinon moyenne), reste au plus fort -- */126  const order = sortParties(seats).concat(Object.keys(seats).includes("AUT") ? ["AUT"] : []);127  const target = order.map(p => ({ p, x: seats[p].p50 != null ? seats[p].p50 : seats[p].mean }));128  const sumX = target.reduce((a, t) => a + t.x, 0) || 1;129  let alloc = target.map(t => ({ p: t.p, n: Math.floor(t.x / sumX * total),130    frac: (t.x / sumX * total) % 1 }));131  let left = total - alloc.reduce((a, t) => a + t.n, 0);132  alloc.sort((a, b) => b.frac - a.frac);133  for (let i = 0; left > 0; i = (i + 1) % alloc.length, left--) alloc[i].n++;134  const nOf = Object.fromEntries(alloc.map(a => [a.p, a.n]));135136  /* -- géométrie : rangées concentriques, sièges ∝ rayon -- */137  const ROWS = 6, W = 640, R0 = 118, DR = 34, SEAT = 8.6;138  const H = R0 + (ROWS - 1) * DR + 52;         // marge haute : ligne + étiquette de majorité139  const CX = W / 2, CY = H - 10;140  const radii = Array.from({ length: ROWS }, (_, i) => R0 + i * DR);141  const wSum = radii.reduce((a, r) => a + r, 0);142  let perRow = radii.map(r => Math.floor(r / wSum * total));143  let rem = total - perRow.reduce((a, b) => a + b, 0);144  for (let i = ROWS - 1; rem > 0; i = (i - 1 + ROWS) % ROWS, rem--) perRow[i]++;145146  const pos = [];147  perRow.forEach((n, row) => {148    const r = radii[row];149    for (let k = 0; k < n; k++) {150      const a = Math.PI - (n === 1 ? Math.PI / 2 : k * Math.PI / (n - 1)); // 180° → 0°151      pos.push({ x: CX + r * Math.cos(a), y: CY - r * Math.sin(a), a });152    }153  });154  pos.sort((u, v) => v.a - u.a); // balayage gauche → droite155156  /* -- attribution des sièges dans l'ordre du balayage -- */157  const seatParty = [];158  for (const p of order) for (let i = 0; i < (nOf[p] || 0); i++) seatParty.push(p);159160  const circles = pos.map((pt, i) => {161    const p = seatParty[i] || "AUT";162    return `<circle class="seat" data-p="${p}" cx="${pt.x.toFixed(1)}" cy="${pt.y.toFixed(1)}"163      r="${SEAT}" fill="${partyColor(p)}"></circle>`;164  }).join("");165166  /* -- marqueur de majorité : à l'angle du 64e siège du balayage -- */167  const mAngle = (pos[majority - 1].a + pos[majority].a) / 2;168  const mx1 = CX + (R0 - 22) * Math.cos(mAngle), my1 = CY - (R0 - 22) * Math.sin(mAngle);169  const mx2 = CX + (radii[ROWS - 1] + 16) * Math.cos(mAngle), my2 = CY - (radii[ROWS - 1] + 16) * Math.sin(mAngle);170171  const el = document.createElement("div");172  el.className = "hemi";173  el.innerHTML = `174    <svg viewBox="0 0 ${W} ${H}" role="img"175      aria-label="Hémicycle : composition attendue des ${total} sièges (majorité à ${majority})">176      ${circles}177      <line x1="${mx1.toFixed(1)}" y1="${my1.toFixed(1)}" x2="${mx2.toFixed(1)}" y2="${my2.toFixed(1)}"178        stroke="currentColor" stroke-width="1.4" stroke-dasharray="3 4" opacity=".55"></line>179      <text x="${mx2.toFixed(1)}" y="${(my2 - 7).toFixed(1)}" text-anchor="middle"180        fill="currentColor" opacity=".75" font-size="12.5" font-weight="650"181        font-family="Inter, sans-serif">majorité ${majority}</text>182      <text x="${CX}" y="${CY - 26}" text-anchor="middle" fill="currentColor" opacity=".55"183        font-size="12" font-family="Inter, sans-serif">${total} sièges</text>184    </svg>185    <div class="hemi-tip" role="status"></div>186    ${legend ? `<div class="hemi-legend">${order.filter(p => nOf[p] > 0).map(p =>187      `<span class="hl-item" data-p="${p}"><span class="sw" style="background:${partyColor(p)}"></span>188        ${p} <b>${nOf[p]}</b> <span class="ci">[${seats[p].p05}–${seats[p].p95}]</span></span>`).join("")}189    </div>` : ""}`;190191  /* -- interactions : hover = met le parti en évidence + info-bulle -- */192  const tip = el.querySelector(".hemi-tip");193  const allSeats = () => el.querySelectorAll(".seat");194  function focus(p) {195    allSeats().forEach(c => c.classList.toggle("dim", p != null && c.dataset.p !== p));196  }197  function showTip(p, ev) {198    const s = seats[p];199    if (!s) return;200    tip.innerHTML = `<b style="color:${partyColor(p)}">${p}</b> — ${nOf[p]} sièges attendus201      <span style="color:var(--muted)">[${s.p05}–${s.p95}]</span>`;202    tip.style.display = "block";203    const r = el.getBoundingClientRect();204    const tw = tip.offsetWidth, th = tip.offsetHeight;205    let x = ev.clientX - r.left + 12, y = ev.clientY - r.top - th - 10;206    x = Math.max(4, Math.min(x, r.width - tw - 4));207    if (y < 0) y = ev.clientY - r.top + 14;208    tip.style.left = x + "px"; tip.style.top = y + "px";209  }210  el.addEventListener("mousemove", ev => {211    const t = ev.target.closest("[data-p]");212    if (t) { focus(t.dataset.p); showTip(t.dataset.p, ev); }213    else { focus(null); tip.style.display = "none"; }214  });215  el.addEventListener("mouseleave", () => { focus(null); tip.style.display = "none"; });216  return el;217}218219/* ============================================================================220   Coquille partagée : header + forecast strip + drawer + footer221   ========================================================================== */222const NAV_LINKS = [223  ["/", "Aperçu"], ["/aujourdhui", "Le point du jour"],224  ["/circonscriptions", "Circonscriptions"], ["/carte", "Carte"],225  ["/sondages", "Sondages"], ["/signaux", "Signaux"], ["/simulateur", "Simulateur"],226  ["/intelligence", "Intelligence"], ["/backtest", "Calibration"],227  ["/methodologie", "Méthodologie"],228];229230/* Fleur-de-lys — icône vectorielle nette (pas d'emoji, pas de pastille). */231const FLEUR_SVG = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">232  <path d="M12 1.2C9.8 4.2 8.9 6.6 8.9 8.7c0 2.4 1.1 5 3.1 8.7 2-3.7 3.1-6.3 3.1-8.7 0-2.1-.9-4.5-3.1-7.5z"/>233  <path d="M9.9 17.4c-.3-4.2-2.5-7-6.9-7.5-.3 3.6.9 6.1 3.4 7.2 1.1.5 2.3.6 3.5.3z"/>234  <path d="M14.1 17.4c.3-4.2 2.5-7 6.9-7.5.3 3.6-.9 6.1-3.4 7.2-1.1.5-2.3.6-3.5.3z"/>235  <rect x="7.2" y="18.3" width="9.6" height="1.9" rx=".95"/>236  <path d="M12 23c-1-.8-1.6-1.7-1.8-2.6h3.6c-.2.9-.8 1.8-1.8 2.6z"/>237</svg>`;238const SUN_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"239  stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4.4"/>240  <path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7"/></svg>`;241const MOON_SVG = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">242  <path d="M20.6 14.6A8.7 8.7 0 0 1 9.4 3.4 8.9 8.9 0 1 0 20.6 14.6z"/></svg>`;243244function headerHTML(active) {245  return `<div class="inner">246    <a href="/" class="brand" style="text-decoration:none" aria-label="QC Élection — accueil">247      <span class="fleur">${FLEUR_SVG}</span>QC Élection<span class="b-sub">&nbsp;Forecast</span></a>248    <nav class="main" aria-label="Navigation principale">${NAV_LINKS.map(([h, t]) =>249      `<a href="${h}" class="${h === active ? "active" : ""}"${h === active ? ' aria-current="page"' : ""}>${t}</a>`).join("")}</nav>250    <div class="updated" id="hdr-updated"></div>251    <button class="theme-toggle" onclick="toggleTheme()"252      aria-label="Basculer vers le thème ${THEME === "dark" ? "clair" : "sombre"}"253      title="Thème ${THEME === "dark" ? "clair" : "sombre"}">${THEME === "dark" ? SUN_SVG : MOON_SVG}</button>254    <button class="nav-burger" id="nav-burger" aria-label="Ouvrir le menu" aria-expanded="false">255      <span></span><span></span><span></span></button>256  </div>257  <div class="fstrip" id="fstrip" hidden></div>`;258}259260/* Forecast strip — fine barre sous la nav, sur toutes les pages.261   P(1er) publiée + variation depuis la veille. Couleur du parti sur le sigle,262   flèche neutre. /api/today, repli /api/forecast/latest. */263async function mountStrip() {264  const host = document.getElementById("fstrip");265  if (!host) return;266  let items = [], right = "";267  try {268    const T = await getJSON(`${API}/today`);269    const order = Object.keys(T.prob_most).filter(p => p !== "AUT")270      .sort((a, b) => T.prob_most[b] - T.prob_most[a]);271    items = order.map(p => {272      const d = T.delta ? T.delta.prob_most[p] * 100 : null;273      const flat = d == null || Math.abs(d) < 0.05;274      return `<a class="fs-item" href="/aujourdhui" title="${partyName(p)} — probabilité d'arriver premier (variation vs veille)">275        <span class="p" style="color:${partyColor(p)}">${p}</span>276        <span class="v">${(T.prob_most[p] * 100).toFixed(0)}</span>277        <span class="dlt" aria-hidden="true">${flat ? "·" : (d > 0 ? "▲" : "▼") + Math.abs(d).toFixed(1)}</span></a>`;278    });279    right = `J−${T.days_to_election}`;280  } catch {281    try {282      const fc = await getJSON(`${API}/forecast/latest`);283      const seats = fc.seats.per_party;284      const blended = (fc.seats.ensemble || {}).blended || {};285      const order = sortParties(seats);286      items = order.map(p => `<a class="fs-item" href="/aujourdhui"287        title="${partyName(p)} — probabilité d'arriver premier">288        <span class="p" style="color:${partyColor(p)}">${p}</span>289        <span class="v">${((blended[p] ?? seats[p].prob_most) * 100).toFixed(0)}</span></a>`);290    } catch { return; } /* pas de forecast : la barre reste masquée */291  }292  host.innerHTML = `<div class="fs-inner">293    <span class="fs-label">P(premier)</span>${items.join("")}294    ${right ? `<span class="fs-right">Scrutin 5 oct. · ${right}</span>` : ""}</div>`;295  host.hidden = false;296}297298/* Tiroir de navigation mobile (hamburger) — verrou de défilement, fermeture par299   fond, lien ou Escape. */300function mountDrawer(active) {301  const drawer = document.createElement("div");302  drawer.className = "drawer";303  drawer.setAttribute("role", "dialog");304  drawer.setAttribute("aria-label", "Menu de navigation");305  drawer.innerHTML = `306    <div class="drawer-head">307      <span class="brand"><span class="fleur">${FLEUR_SVG}</span>QC Élection</span>308      <button class="theme-toggle" onclick="toggleTheme()" aria-label="Basculer le thème">309        ${THEME === "dark" ? SUN_SVG : MOON_SVG}</button>310    </div>311    <nav>${NAV_LINKS.map(([h, t]) =>312      `<a href="${h}" class="${h === active ? "active" : ""}">${t}</a>`).join("")}</nav>313    <div class="drawer-foot"><span id="drawer-updated"></span><span>qc-election.com</span></div>`;314  const backdrop = document.createElement("div");315  backdrop.className = "nav-backdrop";316  document.body.append(backdrop, drawer);317  const toggle = (open) => {318    document.body.classList.toggle("nav-open", open);319    const b = document.getElementById("nav-burger");320    if (b) b.setAttribute("aria-expanded", String(open));321  };322  document.getElementById("nav-burger")?.addEventListener("click",323    () => toggle(!document.body.classList.contains("nav-open")));324  backdrop.addEventListener("click", () => toggle(false));325  drawer.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => toggle(false)));326  document.addEventListener("keydown", e => { if (e.key === "Escape") toggle(false); });327}328329function footerHTML() {330  return `<div class="wrap">331    Plateforme indépendante et politiquement neutre de prévision électorale.332    Le forecast combine sondages, votes réels des partielles, fondamentaux et333    veille web continue — chaque couche est <a href="/signaux">décomposée et traçable</a>.334    Toutes les valeurs sont des <b>probabilités</b>, jamais des certitudes.335    Les mêmes règles statistiques sont appliquées à tous les partis.336    · <a href="/methodologie">Méthodologie complète</a> · <a href="/api/docs">API</a>337    <div class="credits" style="margin-top:8px">338      Données officielles : <a href="https://www.electionsquebec.qc.ca" target="_blank" rel="noopener">Élections Québec</a> (carte, candidatures, dates du scrutin).339      Photos des chef·fe·s et logos des partis : Wikimedia Commons / Wikipédia (identification seulement).340      Contact : Simon-Pierre Boucher · <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>341    </div>342  </div>`;343}344345async function initShell(active) {346  document.querySelector("header.site").innerHTML = headerHTML(active);347  document.querySelector("footer.site").innerHTML = footerHTML();348  mountDrawer(active);349  mountStrip(); // asynchrone, non bloquant350  const meta = await loadMeta();351  if (meta.last_update) {352    const maj = "Dernière MAJ " + new Date(meta.last_update + "Z").toLocaleTimeString("fr-CA",353      { hour: "2-digit", minute: "2-digit" });354    document.getElementById("hdr-updated").textContent = maj;355    const du = document.getElementById("drawer-updated");356    if (du) du.textContent = maj;357  }358  return meta;359}360361/* ============================================================================362   Thème ECharts « qc-signal » : grille recessive, typo Inter, tooltips premium363   ========================================================================== */364const DARKMODE = THEME === "dark";365const GRID_COLOR = DARKMODE ? "#2b3247" : "#c9d2e2";  // lignes de référence (markLine…)366const SPLIT_COLOR = DARKMODE ? "#1c2231" : "#e6eaf2"; // grille de fond, très légère367const INK2 = DARKMODE ? "#aab2c5" : "#414d68";368const MUTED = DARKMODE ? "#7d8699" : "#67718a";369const CHART_FONT = "Inter, -apple-system, 'Segoe UI', sans-serif";370371function baseChart(el) {372  const c = echarts.init(el, null, { renderer: "canvas" });373  const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;374  c.setOption({ textStyle: { fontFamily: CHART_FONT },375    animationDuration: reduce ? 0 : 420, animationEasing: "cubicOut" });376  if (window.ResizeObserver) new ResizeObserver(() => c.resize()).observe(el);377  else window.addEventListener("resize", () => c.resize());378  return c;379}380381/* Dégradé vertical « signature » sous une courbe (remplace les aplats). */382function areaGrad(color, top = 0.22, bottom = 0.0) {383  return { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [384    { offset: 0, color: hexA(color, top) }, { offset: 1, color: hexA(color, bottom) }]) };385}386/* Trait de série : arrondi + halo discret de sa propre couleur. */387function lineGlow(color, width = 2.4) {388  return { width, cap: "round", join: "round",389    shadowColor: hexA(color, DARKMODE ? 0.3 : 0.18), shadowBlur: 6, shadowOffsetY: 3 };390}391function hexA(hex, a) {392  const n = parseInt(hex.slice(1), 16);393  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;394}395396const AXIS_STYLE = {397  axisLine: { show: false },398  axisTick: { show: false },399  axisLabel: { color: MUTED, fontSize: 11, fontFamily: CHART_FONT },400  splitLine: { lineStyle: { color: SPLIT_COLOR, width: 1 } },401};402const TOOLTIP = {403  trigger: "axis",404  backgroundColor: DARKMODE ? "rgba(22,27,39,.97)" : "rgba(255,255,255,.98)",405  borderColor: DARKMODE ? "rgba(170,188,218,.22)" : "rgba(38,54,92,.18)",406  borderWidth: 1,407  padding: [9, 13],408  textStyle: { color: DARKMODE ? "#edeff5" : "#161b26", fontSize: 12.5,409    fontFamily: CHART_FONT },410  extraCssText: "border-radius:12px;box-shadow:0 10px 32px -10px rgba(0,0,0,.45);font-variant-numeric:tabular-nums;",411  axisPointer: { type: "cross",412    lineStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4", type: [4, 4] },413    crossStyle: { color: DARKMODE ? "#39415a" : "#b9c2d4" },414    label: { backgroundColor: DARKMODE ? "#2c3346" : "#414d68", borderRadius: 5,415      fontSize: 10.5, padding: [3, 7] } },416};417const LEGEND = { textStyle: { color: INK2, fontSize: 12, fontFamily: CHART_FONT },418  itemWidth: 10, itemHeight: 10, icon: "circle", top: 0, itemGap: 16,419  inactiveColor: DARKMODE ? "#3a4157" : "#c6cedf" };420