teach/public/js/widgets/noise.js
Ramon d8610b010d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
feat: compacter instellingenmenu voor acht widgets (v0.4.80-beta)
Bij veel widgets nam de instellingenstrook te veel hoogte in, vooral bij een
smal widgetvenster waar de rij naar meerdere regels uitwaaierde. Nieuw
gedeeld patroon: besturing die voortdurend nodig is (kleur, niveau,
woordbron) blijft in één compacte rij zichtbaar; besturing die maar zelden
wordt aangepast (thema, geluid aan/uit, invoerwijze, bereik) gaat achter een
inklapbaar ⚙-paneel. Dit patroon bestond al voor klok/geld/rekengetallen/
wereld (learningShell() in learning.js) - nu als widgetGear()/gearField() in
core.js beschikbaar voor de rest van de app, met dezelfde .edu-settings-
opmaak zodat het overal hetzelfde oogt en werkt.

- kleurplaten: twee volle rijen (thema/plaat + kwast/emmer/gum/kleur/dikte/
  hulp/reset/klaar) teruggebracht naar één vaste rij (gereedschap, kleur,
  dikte, klaar) plus het ingeklapte paneel (thema, plaat, kleurhulp, reset).
  Losstaand: coloring.css gebruikte @media(max-width:620px) in plaats van de
  @container widget-conventie die de rest van de app gebruikt, waardoor die
  regels nooit afgingen bij het verkleinen van het widgetvenster zelf - nu
  gecorrigeerd.
- letterblokken: geluid, invoerwijze en aantal kaarten in het paneel; niveau
  en woordbron blijven vast.
- rekenwidgets (optellen/aftrekken/keer/delen/procenten): geluid en
  invoerwijze in het paneel; niveau blijft vast.
- ballonnenwoord: geluid in het paneel.
- flitswoorden: flitstijd in het paneel.
- getallenlijn: het hele bereik-instelblok (van/tot/stap/nieuw) in het
  paneel - dat stond toch al uit voor leerlingen met een beheerd niveau.
- zinsbouwer: het rijtje voor eigen woorden toevoegen in het paneel; de
  speelknoppen (verrassing/ongedaan/wissen/voorlezen) blijven vast.
- timer: geluid en weergavevorm in het paneel; starttijden en start/reset
  blijven vast.
- geluidsmeter: de twee drempelschuifjes plus de privacynotitie in het
  paneel.

Widgets die dit patroon al hadden (klok, geld, breuken, plaatswaarde,
woordsoorten, bomen/vogels/planten, topografie, stemming, stemming-poll,
dagplanning) of waar de rij al compact genoeg was (schrijfblad, hakken en
plakken, dobbelstenen, notities, naamtrekker) zijn ongewijzigd gelaten.

Geverifieerd door elke aangepaste widget live via de echte makeWidget()/
REGISTRY in een headless browser te openen, het paneel open en dicht te
klappen en te controleren op consolefouten - ook op de minimale
widgetbreedte (coloring, minW 390). 202 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
2026-07-30 16:46:37 +02:00

128 lines
5.2 KiB
JavaScript

/* widget: Geluidsmeter */
/* =========================================================
Live noise meter — mic input stays local, never recorded or sent anywhere
==========================================================*/
function mountNoise(root, initState){
let zoneGreen = (initState && initState.zoneGreen) || 40;
let zoneYellow = (initState && initState.zoneYellow) || 70;
let running = false, audioCtx = null, analyser = null, stream = null, rafId = null, warnTimer = null;
let level = 0, peak = 0, timeData = null;
root.innerHTML = `
<div class="nm">
<p class="play-mission nm-mission"></p>
<div class="nm-stage">
<div class="nm-gauge">
<div class="nm-fill"></div>
<div class="nm-mark green"></div>
<div class="nm-mark yellow"></div>
</div>
<div class="nm-face"></div>
</div>
<output class="nm-status" aria-live="polite"></output>
<button class="tbtn nm-toggle"></button>
<details class="edu-settings widget-gear"><summary>⚙ <span class="nm-settings-label"></span></summary>
<div class="edu-controls">
<div class="nm-zones">
<label class="nm-zone-row"><span class="nm-zone-lbl"></span><input type="range" class="nm-z1" min="10" max="90" step="5"></label>
<label class="nm-zone-row"><span class="nm-zone-lbl"></span><input type="range" class="nm-z2" min="10" max="95" step="5"></label>
</div>
<p class="nm-privacy"></p>
</div></details>
</div>`;
const gauge = root.querySelector(".nm-gauge"),
fill = root.querySelector(".nm-fill"),
markGreen = root.querySelector(".nm-mark.green"),
markYellow = root.querySelector(".nm-mark.yellow"),
face = root.querySelector(".nm-face"),
status = root.querySelector(".nm-status"),
toggle = root.querySelector(".nm-toggle"),
z1 = root.querySelector(".nm-z1"),
z2 = root.querySelector(".nm-z2");
z1.value = zoneGreen;
z2.value = zoneYellow;
function currentZone(){ return level < zoneGreen ? "green" : level < zoneYellow ? "yellow" : "red"; }
function draw(){
fill.style.height = Math.round(level) + "%";
fill.className = "nm-fill " + currentZone();
markGreen.style.bottom = zoneGreen + "%";
markYellow.style.bottom = zoneYellow + "%";
face.textContent = !running ? "🎙️" : currentZone() === "green" ? "🙂" : currentZone() === "yellow" ? "😐" : "😬";
status.textContent = !running ? T("noiseOff")
: T("noiseStatus_" + currentZone());
gauge.classList.toggle("warn", running && currentZone() === "red");
}
function loop(){
if(!running || !document.body.contains(root)){ stop(); return; }
analyser.getByteTimeDomainData(timeData);
let sum = 0;
for(let i = 0; i < timeData.length; i++){ const v = (timeData[i] - 128) / 128; sum += v * v; }
const rms = Math.sqrt(sum / timeData.length);
const pct = Math.min(100, rms * 100 * 4.2);
level = level * 0.75 + pct * 0.25;
peak = Math.max(peak * 0.995, level);
draw();
rafId = requestAnimationFrame(loop);
}
async function start(){
try{
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}catch(e){
status.textContent = T("noiseDenied");
return;
}
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if(audioCtx.state === "suspended") await audioCtx.resume().catch(()=>{});
const source = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
timeData = new Uint8Array(analyser.fftSize);
source.connect(analyser);
running = true; level = 0; peak = 0;
labels();
loop();
}
function stop(){
running = false;
if(rafId) cancelAnimationFrame(rafId);
rafId = null;
if(stream){ stream.getTracks().forEach(t => t.stop()); stream = null; }
analyser = null;
level = 0;
labels();
draw();
}
toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); });
z1.addEventListener("input", ()=>{
zoneGreen = Math.min(+z1.value, +z2.value - 5);
z1.value = zoneGreen;
draw();
});
z2.addEventListener("input", ()=>{
zoneYellow = Math.max(+z2.value, zoneGreen + 5);
z2.value = zoneYellow;
draw();
});
/* geluid over rood laten lopen is een stoorzender op zich - een korte,
zachte waarschuwing volstaat, geen herhaald alarm */
const watchWarn = ()=>{
clearInterval(warnTimer);
warnTimer = setInterval(()=>{
if(running && currentZone() === "red") sndBad();
}, 6000);
};
watchWarn();
function labels(){
root.querySelector(".nm-mission").textContent = T("wg_noise_d");
root.querySelector(".nm-settings-label").textContent = T("widgetSettings");
toggle.textContent = running ? "⏸ " + T("noiseStop") : "🎙️ " + T("noiseStart");
root.querySelectorAll(".nm-zone-lbl")[0].textContent = T("noiseZoneGreen");
root.querySelectorAll(".nm-zone-lbl")[1].textContent = T("noiseZoneYellow");
root.querySelector(".nm-privacy").textContent = T("noisePrivacy");
draw();
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({ zoneGreen, zoneYellow }) };
}