teach/public/js/widgets/learning.js
Ramon 6e17dc5982
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
fix: voeg vrije uurmarkeringen toe
2026-07-15 20:38:30 +02:00

68 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */
function learningShell(root, settings, stage){
root.innerHTML='<div class="edu"><details class="edu-settings"><summary>⚙ <span></span></summary><div class="edu-controls"></div></details><div class="edu-stage"></div></div>';
root.querySelector('summary span').textContent=T('widgetSettings');
root.querySelector('.edu-controls').append(...settings);
root.querySelector('.edu-stage').append(stage);
}
function field(label, el){const w=document.createElement('label');const s=document.createElement('span');s.textContent=label;w.append(s,el);return w}
function btn(text,fn,kind='ghost'){const b=document.createElement('button');b.type='button';b.className='tbtn '+kind;b.textContent=text;b.onclick=fn;return b}
function num(value,min,max){const i=document.createElement('input');i.type='number';i.value=value;i.min=min;i.max=max;return i}
function mountFractions(root,init){
const S=Object.assign({den:4,show:true},init||{}), den=num(S.den,2,12), show=document.createElement('input'), stage=document.createElement('div');show.type='checkbox';show.checked=S.show;
const draw=()=>{S.den=Math.max(2,Math.min(12,+den.value||4));S.show=show.checked;stage.className='fraction-wall';stage.innerHTML='';for(let d=1;d<=S.den;d++){const row=document.createElement('div');for(let n=0;n<d;n++){const c=document.createElement('button');c.type='button';c.style.setProperty('--fraction-hue',d*31);c.textContent=S.show?`1/${d}`:'';c.className='fraction-piece';c.onclick=()=>c.classList.toggle('selected');row.append(c)}stage.append(row)}};
den.onchange=show.onchange=draw;learningShell(root,[field(T('fractionRows'),den),field(T('fractionLabels'),show)],stage);draw();return{getState:()=>({...S})};
}
function mountClock(root,init){
const S=Object.assign({h:12,m:0,level:'quarter',style:'both',game:'set',task:false,targetH:3,targetM:30,score:0,streak:0,solved:false,feedback:'',choices:[],answerDigits:[null,null,null,null]},init||{});
const level=document.createElement('select'),style=document.createElement('select'),game=document.createElement('select'),stage=document.createElement('div');
const add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value)));
add(level,[['hour','clockLevelHour'],['quarter','clockLevelQuarter'],['five','clockLevelFive'],['minute','clockLevelMinute']]);
add(style,[['analogue','clockStyleAnalogue'],['digital','clockStyleDigital'],['words','clockStyleWords'],['both','clockStyleBoth']]);
add(game,[['set','clockGameSet'],['read','clockGameRead'],['choose','clockGameChoose']]);
level.value=S.level;style.value=S.style;game.value=S.game;
const minuteValues=()=>S.level==='hour'?[0]:S.level==='quarter'?[0,15,30,45]:S.level==='five'?Array.from({length:12},(_,i)=>i*5):Array.from({length:60},(_,i)=>i);
const time=(hour,minute)=>`${hour}:${String(minute).padStart(2,'0')}`;
const words=(hour,minute)=>{const next=hour===12?1:hour+1;if(minute===0)return T('clockWordsHour').replace('{hour}',hour);if(minute===15)return T('clockWordsQuarterPast').replace('{hour}',hour);if(minute===30)return T('clockWordsHalf').replace('{hour}',next);if(minute===45)return T('clockWordsQuarterTo').replace('{hour}',next);return T(minute<30?'clockWordsPast':'clockWordsTo').replace('{minutes}',minute<30?minute:60-minute).replace('{hour}',minute<30?hour:next)};
const randomTime=()=>{const values=minuteValues();return{h:1+Math.floor(Math.random()*12),m:values[Math.floor(Math.random()*values.length)]}};
const woodDigit=value=>{const block=document.createElement('span');block.className='blok sm clock-digit-block';const label=document.createElement('span');label.className='bl';label.textContent=String(value);const dots=dotGrid(Number(value));dots.classList.add('blok-dots');block.append(label,dots);return block};
const timeBlocks=(hour,minute,click)=>{const el=document.createElement(click?'button':'div');if(click)el.type='button';el.className='clock-time-blocks';el.setAttribute('aria-label',time(hour,minute));const digits=String(hour).padStart(2,'0')+String(minute).padStart(2,'0');el.append(woodDigit(digits[0]),woodDigit(digits[1]));const colon=document.createElement('strong');colon.textContent=':';el.append(colon,woodDigit(digits[2]),woodDigit(digits[3]));if(click)el.onclick=click;return el};
const makeClock=(hour,minute,interactive)=>{const face=document.createElement('div');face.className='clock'+(interactive?' interactive':'');face.setAttribute('role','img');face.setAttribute('aria-label',time(hour,minute));for(let i=0;i<60;i++){const tick=document.createElement('i');tick.className=i%5===0?'clock-tick hour':'clock-tick minor';tick.style.transform=`rotate(${i*6}deg)`;face.append(tick)}[12,1,2,3,4,5,6,7,8,9,10,11].forEach((n,i)=>{const mark=document.createElement('b');mark.textContent=n;mark.style.setProperty('--clock-angle',`${i*30}deg`);face.append(mark)});const hourHand=document.createElement('i'),minuteHand=document.createElement('i'),pin=document.createElement('span');hourHand.className='clock-hand clock-hour';minuteHand.className='clock-hand clock-min';pin.className='clock-pin';const paint=()=>{hourHand.style.transform=`rotate(${S.h*30+S.m/2}deg)`;minuteHand.style.transform=`rotate(${S.m*6}deg)`};if(!interactive){hourHand.style.transform=`rotate(${hour*30+minute/2}deg)`;minuteHand.style.transform=`rotate(${minute*6}deg)`}else paint();face.append(hourHand,minuteHand,pin);if(interactive){const move=e=>{const r=face.getBoundingClientRect(),dx=e.clientX-r.left-r.width/2,dy=e.clientY-r.top-r.height/2,deg=(Math.atan2(dx,-dy)*180/Math.PI+360)%360;if(face.dataset.hand==='minute'){const wanted=Math.round(deg/6)%60,allowed=minuteValues(),distance=v=>Math.min(Math.abs(v-wanted),60-Math.abs(v-wanted));S.m=allowed.reduce((best,v)=>distance(v)<distance(best)?v:best)}else S.h=(Math.round((deg-S.m/2)/30)+11)%12+1;paint();const answer=stage.querySelector('.clock-live-answer');if(answer){answer.innerHTML='';answer.append(timeBlocks(S.h,S.m))}};face.onpointerdown=e=>{if(S.solved)return;e.preventDefault();const r=face.getBoundingClientRect(),dx=e.clientX-r.left-r.width/2,dy=e.clientY-r.top-r.height/2;face.dataset.hand=Math.hypot(dx,dy)>r.width*.22?'minute':'hour';face.setPointerCapture(e.pointerId);move(e)};face.onpointermove=e=>{if(face.hasPointerCapture(e.pointerId))move(e)};face.onpointerup=e=>{move(e);face.releasePointerCapture(e.pointerId);delete face.dataset.hand};face.onpointercancel=e=>{if(face.hasPointerCapture(e.pointerId))face.releasePointerCapture(e.pointerId);delete face.dataset.hand}}return face};
const blockAnswer=()=>{const answer=document.createElement('div');answer.className='clock-block-answer';const instruction=document.createElement('p');instruction.textContent=T('clockBuildHint');const slots=document.createElement('div');slots.className='clock-answer-slots';const makeSlot=index=>{const slot=document.createElement('div');slot.className='slot sm clock-digit-slot'+(S.answerDigits[index]!==null?' filled':'');slot.dataset.index=index;const ghost=document.createElement('div');ghost.className='ghost';ghost.textContent=T('clockDigitGhost');const dots=dotGrid(S.answerDigits[index]===null?0:S.answerDigits[index]);dots.classList.add('slot-dots');slot.append(ghost,dots);if(S.answerDigits[index]!==null){const placed=woodDigit(S.answerDigits[index]);placed.classList.add('in-slot','clock-drag-block');placed.dataset.placedAt=Date.now();slot.append(placed);slot.onclick=()=>{if(!S.solved){S.answerDigits[index]=null;S.feedback='';draw()}}}return slot};slots.append(makeSlot(0),makeSlot(1));const colon=document.createElement('strong');colon.className='clock-slot-colon';colon.textContent=':';slots.append(colon,makeSlot(2),makeSlot(3));const tray=document.createElement('div');tray.className='clock-block-tray lg-tray';const attachDrag=block=>{block.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();try{block.releasePointerCapture(e.pointerId)}catch(_){}const clone=block.cloneNode(true);clone.id='dragClone';document.body.append(clone);block.classList.add('dragging');const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},hit=ev=>{let found=null;slots.querySelectorAll('.clock-digit-slot').forEach(slot=>{const r=slot.getBoundingClientRect(),pad=8;if(ev.clientX>=r.left-pad&&ev.clientX<=r.right+pad&&ev.clientY>=r.top-pad&&ev.clientY<=r.bottom+pad)found=slot});return found};moveTo(e);let hovered=null;const move=ev=>{moveTo(ev);const target=hit(ev);if(hovered&&hovered!==target)hovered.classList.remove('droptarget');hovered=target;if(hovered)hovered.classList.add('droptarget')},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);clone.remove();block.classList.remove('dragging');if(hovered)hovered.classList.remove('droptarget')},up=ev=>{const target=hit(ev);clean();if(target){S.answerDigits[+target.dataset.index]=+block.dataset.digit;S.feedback='';draw()}},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)}};'0123456789'.split('').forEach(d=>{const block=woodDigit(+d);block.classList.add('clock-drag-block');block.dataset.digit=d;block.setAttribute('role','button');block.setAttribute('aria-label',T('clockDigit').replace('{digit}',d));block.tabIndex=0;block.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!S.solved){e.preventDefault();const index=S.answerDigits.indexOf(null);if(index>=0){S.answerDigits[index]=+d;draw()}}};attachDrag(block);tray.append(block)});answer.append(instruction,slots,tray);return answer};
const setFeedback=ok=>{if(S.solved)return;if(ok){S.solved=true;S.score++;S.streak++;S.feedback='good';sndGood()}else{S.streak=0;S.feedback='bad';sndBad()}draw()};
const check=()=>{if(!S.task)return;if(S.game==='read'){if(S.answerDigits.some(x=>x===null)){S.feedback='incomplete';sndBad();draw();return}S.h=Number(''+S.answerDigits[0]+S.answerDigits[1]);S.m=Number(''+S.answerDigits[2]+S.answerDigits[3])}setFeedback(S.h===S.targetH&&S.m===S.targetM)};
const newTask=()=>{const target=randomTime();S.targetH=target.h;S.targetM=target.m;S.h=12;S.m=0;S.task=true;S.solved=false;S.feedback='';S.choices=[];S.answerDigits=[null,null,null,null];if(S.game==='choose'){S.choices=[target];while(S.choices.length<3){const option=randomTime();if(!S.choices.some(x=>x.h===option.h&&x.m===option.m))S.choices.push(option)}S.choices.sort(()=>Math.random()-.5)}draw()};
const draw=()=>{S.level=level.value;S.style=style.value;S.game=game.value;stage.className='clock-app';stage.innerHTML='';const top=document.createElement('div');top.className='clock-top';const score=document.createElement('span');score.className='clock-score';score.textContent=T('clockScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(score);stage.append(top);const card=document.createElement('section');card.className='clock-task-card';const title=document.createElement('h3');title.textContent=!S.task?T('clockWelcome'):S.game==='set'?T('clockSetTitle'):S.game==='read'?T('clockReadTitle'):T('clockChooseTitle');card.append(title);
if(!S.task){const intro=document.createElement('p');intro.className='clock-intro';intro.textContent=T('clockWelcomeHint');card.append(intro)}
if(S.task&&S.game==='set'){const prompt=document.createElement('div');prompt.className='clock-target';const label=document.createElement('span');label.textContent=T('clockYourTask');prompt.append(label);if(S.style==='analogue'){const example=makeClock(S.targetH,S.targetM,false);example.classList.add('clock-reference');prompt.append(example)}if(['digital','both'].includes(S.style))prompt.append(timeBlocks(S.targetH,S.targetM));if(['words','both'].includes(S.style)){const written=document.createElement('strong');written.className='clock-target-words';written.textContent=words(S.targetH,S.targetM);prompt.append(written)}card.append(prompt);const work=document.createElement('div');work.className='clock-work';work.append(makeClock(S.h,S.m,true));const live=document.createElement('div');live.className='clock-live-answer';live.append(timeBlocks(S.h,S.m));work.append(live);card.append(work)}
if(S.task&&S.game!=='set'){const work=document.createElement('div');work.className='clock-work';work.append(makeClock(S.targetH,S.targetM,false));if(S.game==='read'){const layout=document.createElement('div');layout.className='clock-read-layout';layout.append(work,blockAnswer());card.append(layout)}else card.append(work)}
if(S.task&&S.game==='choose'&&!S.solved){const choices=document.createElement('div');choices.className='clock-choices wood';S.choices.forEach(option=>{const choose=()=>setFeedback(option.h===S.targetH&&option.m===S.targetM);if(S.style==='words'){const b=btn(words(option.h,option.m),choose);b.classList.add('clock-word-choice');choices.append(b)}else choices.append(timeBlocks(option.h,option.m,choose))});card.append(choices)}
const feedback=document.createElement('p');feedback.className='clock-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'clockCorrect':S.feedback==='incomplete'?'clockIncomplete':'clockTryAgain');card.append(feedback);const actions=document.createElement('div');actions.className='clock-actions';if(S.task&&S.game!=='choose'&&!S.solved)actions.append(btn(T('numberlineCheck'),check,''));actions.append(btn(T(S.task?'clockNextTask':'clockStartTask'),newTask,S.task?'ghost':''));card.append(actions);if(S.task&&S.game==='set'){const hint=document.createElement('small');hint.className='clock-drag-hint';hint.textContent=T('clockDragHint');card.append(hint)}stage.append(card)};
level.onchange=()=>{S.level=level.value;if(!minuteValues().includes(S.m))S.m=0;S.task=false;S.solved=false;S.feedback='';draw()};style.onchange=draw;game.onchange=()=>{S.task=false;S.feedback='';draw()};learningShell(root,[field(T('clockLevel'),level),field(T('clockLearningStyle'),style),field(T('clockGame'),game)],stage);draw();return{getState:()=>({...S,choices:S.choices.map(x=>({...x})),answerDigits:[...S.answerDigits]})};
}
function mountPlacevalue(root,init){
const S=Object.assign({number:1342},init||{}),input=num(S.number,0,999999),stage=document.createElement('div');
const draw=()=>{S.number=Math.max(0,Math.min(999999,+input.value||0));const digits=String(S.number).padStart(6,'0').split('');stage.className='place-grid';stage.innerHTML='';['HD','TD','D','H','T','E'].forEach((x,i)=>{const d=document.createElement('div');const b=document.createElement('b');b.textContent=x;const o=document.createElement('output');o.textContent=digits[i];d.append(b,o);stage.append(d)})};input.oninput=draw;learningShell(root,[field(T('placeNumber'),input),btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);draw()})],stage);draw();return{getState:()=>({...S})};
}
function mountMoney(root,init){
const S=Object.assign({target:3.75,total:0},init||{}),stage=document.createElement('div'),out=document.createElement('output');
const draw=()=>{stage.className='money';stage.innerHTML='';const task=document.createElement('h3');task.textContent=T('moneyMake').replace('{amount}',S.target.toFixed(2));stage.append(task);[2,1,.5,.2,.1,.05].forEach(v=>stage.append(btn('€ '+v.toFixed(2),()=>{S.total=Math.round((S.total+v)*100)/100;renderTotal()})));stage.append(out,btn(T('sentenceClear'),()=>{S.total=0;renderTotal()}),btn(T('numberlineCheck'),()=>{S.total===S.target?sndGood():sndBad()},''));renderTotal()};const renderTotal=()=>out.textContent='€ '+S.total.toFixed(2);learningShell(root,[btn(T('numberlineNew'),()=>{S.target=(50+Math.floor(Math.random()*950))/100;S.total=0;draw()})],stage);draw();return{getState:()=>({...S})};
}
function mountWordtypes(root,init){
const S=Object.assign({sentence:'De vrolijke hond rent snel',answers:{}},init||{}),input=document.createElement('input'),stage=document.createElement('div');input.value=S.sentence;
const types=['noun','verb','adjective','other'],draw=()=>{S.sentence=input.value.slice(0,120);stage.className='wordtypes';stage.innerHTML='';S.sentence.split(/\s+/).filter(Boolean).forEach((word,i)=>{const w=document.createElement('button');w.type='button';w.textContent=word;const type=S.answers[i]||'other';w.dataset.type=type;w.onclick=()=>{S.answers[i]=types[(types.indexOf(w.dataset.type)+1)%types.length];draw()};stage.append(w)});const legend=document.createElement('p');legend.textContent=T('wordtypeHint');stage.append(legend)};input.oninput=draw;learningShell(root,[field(T('wordtypeSentence'),input)],stage);draw();return{getState:()=>({...S})};
}
function mountSchedule(root,init){
const S=Object.assign({items:[{time:'08:30',text:'Start'},{time:'10:15',text:'Pauze'}]},init||{}),time=document.createElement('input'),text=document.createElement('input'),stage=document.createElement('div');time.type='time';time.value='09:00';text.placeholder=T('scheduleActivity');
const draw=()=>{stage.className='schedule';stage.innerHTML='';S.items.sort((a,b)=>a.time.localeCompare(b.time)).forEach((it,i)=>{const row=document.createElement('div');const t=document.createElement('time');t.textContent=it.time;const s=document.createElement('span');s.textContent=it.text;row.append(t,s,btn('×',()=>{S.items.splice(i,1);draw()}));stage.append(row)});if(!S.items.length){const e=document.createElement('p');e.className='edu-empty';e.textContent=T('scheduleEmpty');stage.append(e)}};
learningShell(root,[time,text,btn(T('addWord'),()=>{if(text.value.trim()){S.items.push({time:time.value,text:text.value.trim().slice(0,50)});text.value='';draw()}})],stage);draw();return{getState:()=>({...S,items:S.items.map(x=>({...x}))})};
}
function mountMood(root,init){
const S=Object.assign({votes:[0,0,0,0,0]},init||{}),stage=document.createElement('div'),faces=['😢','😕','😐','🙂','🤩'];
const draw=()=>{stage.className='mood';stage.innerHTML='';faces.forEach((f,i)=>{const b=btn(f,()=>{S.votes[i]++;draw()});const n=document.createElement('span');n.textContent=S.votes[i];b.append(n);stage.append(b)})};learningShell(root,[btn(T('pollReset'),()=>{S.votes=[0,0,0,0,0];draw()})],stage);draw();return{getState:()=>({...S,votes:[...S.votes]})};
}
function mountPoll(root,init){
const S=Object.assign({question:'Wat kies jij?',options:[{text:'A',votes:0},{text:'B',votes:0}]},init||{}),question=document.createElement('input'),options=document.createElement('textarea'),stage=document.createElement('div');question.value=S.question;options.value=S.options.map(x=>x.text).join('\n');
const sync=()=>{S.question=question.value.slice(0,100);const old=new Map(S.options.map(x=>[x.text,x.votes]));S.options=options.value.split('\n').map(x=>x.trim()).filter(Boolean).slice(0,6).map(text=>({text,votes:old.get(text)||0}));draw()},draw=()=>{stage.className='poll';stage.innerHTML='';const h=document.createElement('h3');h.textContent=S.question;stage.append(h);const max=Math.max(1,...S.options.map(x=>x.votes));S.options.forEach(o=>{const b=btn(o.text,()=>{o.votes++;draw()});const bar=document.createElement('i');bar.style.width=(o.votes/max*100)+'%';const n=document.createElement('span');n.textContent=o.votes;b.append(bar,n);stage.append(b)})};question.oninput=options.oninput=sync;learningShell(root,[field(T('pollQuestion'),question),field(T('pollOptions'),options),btn(T('pollReset'),()=>{S.options.forEach(x=>x.votes=0);draw()})],stage);draw();return{getState:()=>({...S,options:S.options.map(x=>({...x}))})};
}