teach/public/js/widgets/breathe.js
Ramon 9920fd3855
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
feat: vier sociaal-emotionele widgets, waaronder een geluidsmeter (v0.4.21-beta)
- Geluidsmeter: meet het klasgeluid live via de microfoon in een verticale
  meter (rustig/pratend/te luid). Twee aanpasbare streefniveaus ("Rustig
  tot" / "Te luid vanaf") laten de leerkracht de meter afstemmen op het
  eigen lokaal en apparaat. Microfoon staat standaard uit en start alleen
  na een expliciete klik; er wordt niets opgenomen of verstuurd, alles
  blijft lokaal in de browser. Stopt de microfoonstream vanzelf zodra de
  widget van het bord verdwijnt, ook als "Stop" niet eerst is ingedrukt.
- Gevoelszones: individuele check-in met vier zones (blauw/groen/geel/
  rood, Zones of Regulation-stijl) - elke zone geeft een korte uitleg én
  een concrete tip om jezelf te helpen. Anders dan de bestaande
  Gevoelsmeter (mood.js), die een anonieme klaspoll is.
- Ademhalingsoefening: een groeiende/krimpende cirkel begeleidt rustig
  ademhalen in drie instelbare tempo's (4-4, 4-7-8, box breathing),
  met rondjesteller.
- Complimentenmachine: trekt een willekeurig compliment uit twintig
  kindvriendelijke complimenten, met dezelfde spin-en-onthul-animatie als
  de namenkiezer.
- Alle vier volledig tweetalig (nl/en), toegevoegd aan de mobiele-dekking-
  test, en geverifieerd in de browser inclusief een echte (fake) micro-
  foonstream voor de geluidsmeter.
2026-07-19 00:44:39 +02:00

81 lines
3.6 KiB
JavaScript

/* widget: Ademhalingsoefening */
/* =========================================================
Rustgevende ademhaling met een groeiende/krimpende cirkel, drie tempo's.
De cirkelgrootte wordt via inline style/transition-duration gestuurd
(niet via CSS-klassen) zodat elke fase precies aansluit op de vorige
zonder terugspringen.
==========================================================*/
const BREATHE_PATTERNS = { "4-4": [["in",4],["out",4]], "4-7-8": [["in",4],["hold",7],["out",8]], "4-4-4-4": [["in",4],["hold",4],["out",4],["hold",4]] };
const BREATHE_SCALE = { in: 1.4, hold: null, out: .7 };
function mountBreathe(root, initState){
let pattern = (initState && BREATHE_PATTERNS[initState.pattern]) ? initState.pattern : "4-4";
let running = false, phase = -1, phaseStart = 0, rafId = null, rounds = 0;
root.innerHTML = `
<div class="bre">
<p class="play-mission bre-mission"></p>
<div class="bre-stage">
<div class="bre-circle"><span class="bre-word"></span><span class="bre-count"></span></div>
</div>
<output class="bre-rounds" aria-live="polite"></output>
<div class="bre-row">
<select class="bre-pattern"></select>
<button class="tbtn bre-toggle"></button>
</div>
</div>`;
const circle = root.querySelector(".bre-circle"), word = root.querySelector(".bre-word"),
count = root.querySelector(".bre-count"), roundsEl = root.querySelector(".bre-rounds"),
patternSel = root.querySelector(".bre-pattern"), toggle = root.querySelector(".bre-toggle");
Object.keys(BREATHE_PATTERNS).forEach(k=>patternSel.appendChild(new Option("", k)));
patternSel.value = pattern;
function updateRounds(){ roundsEl.textContent = T("breRounds").replace("{n}", rounds); }
function enterPhase(index){
const [name, dur] = BREATHE_PATTERNS[pattern][index];
circle.style.transitionDuration = dur + "s";
circle.classList.toggle("in", name === "in");
circle.classList.toggle("out", name === "out");
if(BREATHE_SCALE[name] !== null) circle.style.transform = "scale(" + BREATHE_SCALE[name] + ")";
word.textContent = T("bre_" + name);
}
function step(ts){
if(!running || !document.body.contains(root)) return;
const steps = BREATHE_PATTERNS[pattern];
if(phase < 0){ phase = 0; phaseStart = ts; enterPhase(phase); }
const [, dur] = steps[phase];
const elapsed = (ts - phaseStart) / 1000;
count.textContent = Math.max(0, Math.ceil(dur - elapsed)) || "";
if(elapsed >= dur){
phase = (phase + 1) % steps.length;
phaseStart = ts;
if(phase === 0){ rounds++; updateRounds(); }
enterPhase(phase);
}
rafId = requestAnimationFrame(step);
}
function start(){
running = true; phase = -1; rounds = 0;
updateRounds();
labels();
rafId = requestAnimationFrame(step);
}
function stop(){
running = false;
if(rafId) cancelAnimationFrame(rafId);
rafId = null;
circle.classList.remove("in","out");
circle.style.transform = ""; circle.style.transitionDuration = "";
word.textContent = ""; count.textContent = "";
labels();
}
toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); });
patternSel.addEventListener("change", ()=>{ pattern = patternSel.value; if(running) stop(); });
function labels(){
root.querySelector(".bre-mission").textContent = T("wg_breathe_d");
toggle.textContent = running ? "⏸ " + T("breStop") : "🌬️ " + T("breStart");
[...patternSel.options].forEach(o=>{ o.textContent = T("brePattern_" + o.value); });
updateRounds();
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({ pattern }) };
}