teach/public/js/widgets/timer.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

153 lines
6 KiB
JavaScript

/* widget: Timer */
/* =========================================================
Timer widget — digits, visual "kijkklok" disc, or both
==========================================================*/
function mountTimer(root, initState){
const DIAL = 3600; // the disc is a 60-minute dial, like a real kijkklok
let total = 300, left = 300, iv = null,
mode = "both", // dig | pie | both
snd = true;
if(initState){
total = initState.total || 300; left = total;
mode = initState.mode || "both";
snd = initState.snd !== false;
}
root.innerHTML = `
<div class="timer m-both">
<details class="edu-settings widget-gear"><summary>⚙ <span class="topts-label"></span></summary>
<div class="topts edu-controls">
<button class="bsnd ticon"></button>
<div class="tseg">
<button data-m="dig">🔢</button>
<button data-m="pie">🔴</button>
<button data-m="both">🔢🔴</button>
</div>
</div></details>
<div class="tstage">
<svg class="tpie" viewBox="0 0 150 150">
<circle cx="75" cy="75" r="70" fill="#eef3f9" stroke="#d5dfeb" stroke-width="2"/>
<g class="ticks"></g>
<path class="parc" fill="var(--red)"/>
<circle class="knob" r="7" fill="#fff" stroke="var(--red)" stroke-width="3"/>
<circle cx="75" cy="75" r="6" fill="#fff" stroke="#b9c7d9" stroke-width="2"/>
</svg>
<div class="tdisp">05:00</div>
</div>
<div class="trow presets"></div>
<div class="trow main">
<button class="bstart"></button>
<button class="breset"></button>
</div>
<div class="thint"></div>
</div>`;
const box = root.querySelector(".timer"),
svg = root.querySelector(".tpie"),
disp = root.querySelector(".tdisp"),
arc = root.querySelector(".parc"),
knob = root.querySelector(".knob"),
bstart = root.querySelector(".bstart"),
breset = root.querySelector(".breset"),
bsnd = root.querySelector(".bsnd"),
presets = root.querySelector(".presets"),
hint = root.querySelector(".thint");
const fmt = s => String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
/* minute tick marks (every 5 min) */
const ticks = root.querySelector(".ticks");
for(let i=0;i<12;i++){
const a = i*30*Math.PI/180;
const l = document.createElementNS("http://www.w3.org/2000/svg","line");
l.setAttribute("x1", 75+62*Math.sin(a)); l.setAttribute("y1", 75-62*Math.cos(a));
l.setAttribute("x2", 75+69*Math.sin(a)); l.setAttribute("y2", 75-69*Math.cos(a));
l.setAttribute("stroke", "#b9c7d9"); l.setAttribute("stroke-width", i%3===0?2.5:1.5);
ticks.appendChild(l);
}
function drawPie(){
const a = Math.min(360, left/DIAL*360);
const rad = a*Math.PI/180;
const kx = 75 + 63*Math.sin(rad), ky = 75 - 63*Math.cos(rad);
knob.setAttribute("cx", kx.toFixed(2)); knob.setAttribute("cy", ky.toFixed(2));
if(a<=0){ arc.setAttribute("d",""); return; }
if(a>=359.99){ arc.setAttribute("d","M75,5 A70,70 0 1 1 74.99,5 Z"); return; }
const x = 75 + 70*Math.sin(rad), y = 75 - 70*Math.cos(rad);
arc.setAttribute("d",`M75,75 L75,5 A70,70 0 ${a>180?1:0} 1 ${x.toFixed(2)},${y.toFixed(2)} Z`);
}
function draw(){
disp.textContent = fmt(left);
disp.classList.toggle("warn", left<=10 && left>0 && iv!==null);
drawPie();
}
function labels(){
root.querySelector(".topts-label").textContent = T("widgetSettings");
bstart.textContent = (iv ? "⏸ "+T("pause") : "▶ "+T("start"));
breset.textContent = "↺ "+T("reset");
presets.querySelectorAll("button").forEach(b=>b.textContent = b.dataset.m+" "+T("min"));
root.querySelectorAll(".tseg button").forEach(b=>{
const key = b.dataset.m==="dig" ? "tmDigits" : b.dataset.m==="pie" ? "tmVisual" : "tmBoth";
b.title = T(key);
b.classList.toggle("on", b.dataset.m===mode);
});
bsnd.textContent = snd ? "🔔" : "🔕";
bsnd.title = T("tmSound");
bsnd.classList.toggle("on", snd);
box.className = "timer m-"+mode + (box.classList.contains("flash")?" flash":"");
hint.textContent = mode==="dig" ? "" : T("tmHint");
}
[1,5,10,15,30].forEach(m=>{
const b = document.createElement("button");
b.dataset.m = m;
b.addEventListener("click", ()=>{ stop(); total=left=m*60; draw(); labels(); });
presets.appendChild(b);
});
root.querySelectorAll(".tseg button").forEach(b=>{
b.addEventListener("click", ()=>{ mode = b.dataset.m; labels(); });
});
bsnd.addEventListener("click", ()=>{ snd=!snd; labels(); });
/* drag on the disc to set the time */
function angleFromEvent(ev){
const r = svg.getBoundingClientRect();
const px = (ev.clientX - r.left)*150/r.width - 75;
const py = (ev.clientY - r.top)*150/r.height - 75;
return (Math.atan2(px, -py)*180/Math.PI + 360) % 360;
}
svg.addEventListener("pointerdown", e=>{
e.preventDefault();
svg.setPointerCapture(e.pointerId);
const setFrom = ev=>{
const mins = Math.min(60, Math.max(1, Math.round(angleFromEvent(ev)/6)));
total = left = mins*60;
draw();
};
setFrom(e);
const move = ev=>setFrom(ev);
const up = ()=>{ svg.removeEventListener("pointermove",move); svg.removeEventListener("pointerup",up); };
svg.addEventListener("pointermove",move);
svg.addEventListener("pointerup",up);
});
function stop(){ if(iv){ clearInterval(iv); iv=null; } }
bstart.addEventListener("click", ()=>{
if(iv){ stop(); }
else if(left>0){
iv = setInterval(()=>{
left--;
if(left<=0){
left=0; stop();
if(snd) sndWin();
box.classList.add("flash");
setTimeout(()=>box.classList.remove("flash"), 2200);
labels();
}
draw();
},1000);
}
labels(); draw();
});
breset.addEventListener("click", ()=>{ stop(); left=total; draw(); labels(); });
document.addEventListener("langchange", labels);
labels(); draw();
return { getState: ()=>({total, mode, snd}) };
}