From 66fbac7221b2101168ba650e3b6964444e423f75 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 08:53:09 +0200 Subject: [PATCH] fix: laat de oefenklok door alle 24 uren draaien (v0.3.67-beta) --- VERSION | 2 +- public/js/widgets/learning.js | 29 ++++++++++++++++++++++++++--- test/widgets.test.js | 11 ++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 22b05c6..018a402 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.66-beta +0.3.67-beta diff --git a/public/js/widgets/learning.js b/public/js/widgets/learning.js index cd50337..78c99c0 100644 --- a/public/js/widgets/learning.js +++ b/public/js/widgets/learning.js @@ -653,6 +653,9 @@ function mountFractions(root,init){ 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;nc.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})}; } +const clockTurnDelta=(next,previous)=>((next-previous+540)%360)-180; +const clock24Angle=(angle,delta)=>(angle+delta+720)%720; +const clock24Hour=(angle,minute)=>((Math.round((angle-minute/2)/30)%24)+24)%24; function mountClock(root,init){ const S=Object.assign({h:12,m:0,level:1,style:'both',game:'set',amode:'blocks',input:'',linkHands:true,use24:true,task:true,targetH:3,targetM:30,score:0,streak:0,solved:false,feedback:'',choices:[],answerDigits:[null,null,null,null]},init||{}); if(typeof S.level==='string')S.level={hour:1,quarter:2,five:2,minute:3}[S.level]||1;S.level=Math.max(1,Math.min(3,+S.level||1));S.amode=S.amode==='type'?'type':'blocks';S.input=String(S.input||'').replace(/\D/g,'').slice(0,4); @@ -661,14 +664,34 @@ function mountClock(root,init){ const minuteValues=()=>S.level===1?[0,30]:S.level===2?Array.from({length:12},(_,i)=>i*5):Array.from({length:60},(_,i)=>i),time=(hour,minute)=>`${hour}:${String(minute).padStart(2,'0')}`; const uses24=()=>S.level===3&&S.use24; const daypartKey=hour=>hour<6?'clockNight':hour<12?'clockMorning':hour<18?'clockAfternoon':'clockEvening',clockHour=hour=>hour%12||12; - const inputHour=dialHour=>uses24()?(dialHour%12)+(S.h>=12?12:0):dialHour; const words=(hour,minute)=>{const shown=clockHour(hour),next=shown===12?1:shown+1;let result;if(minute===0)result=T('clockWordsHour').replace('{hour}',shown);else if(minute===15)result=T('clockWordsQuarterPast').replace('{hour}',shown);else if(minute===30)result=T('clockWordsHalf').replace('{hour}',next);else if(minute===45)result=T('clockWordsQuarterTo').replace('{hour}',next);else result=T(minute<30?'clockWordsPast':'clockWordsTo').replace('{minutes}',minute<30?minute:60-minute).replace('{hour}',minute<30?shown:next);return uses24()?result+' '+T(daypartKey(hour)):result}; const randomTime=()=>{const values=minuteValues();return{h:uses24()?Math.floor(Math.random()*24):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)paint();else{hourHand.style.transform=`rotate(${hour*30+minute/2}deg)`;minuteHand.style.transform=`rotate(${minute*6}deg)`}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){const angle=(hour%12)*30+minute/2,difference=Math.min(Math.abs(angle-deg),360-Math.abs(angle-deg));if(difference{if(S.solved)return;e.preventDefault();e.stopPropagation();face.dataset.hand=hand;face.setPointerCapture(e.pointerId);move(e)};hourHand.onpointerdown=e=>start(e,'hour');minuteHand.onpointerdown=e=>start(e,'minute');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 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)paint();else{hourHand.style.transform=`rotate(${hour*30+minute/2}deg)`;minuteHand.style.transform=`rotate(${minute*6}deg)`}face.append(hourHand,minuteHand,pin); + if(interactive){ + const updateAnswer=()=>{face.setAttribute('aria-label',time(S.h,S.m));const answer=stage.querySelector('.clock-live-answer');if(answer){answer.innerHTML='';answer.append(timeBlocks(S.h,S.m))}stage.querySelectorAll('.clock-period .tbtn').forEach(button=>{const active=+button.dataset.period===(S.h>=12?12:0);button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active))})}; + 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){const angle=hour*30+minute/2,difference=Math.min(Math.abs(angle-face._dragAngle),720-Math.abs(angle-face._dragAngle));if(difference{const angle=(hour%12)*30+minute/2,difference=Math.min(Math.abs(angle-deg),360-Math.abs(angle-deg));if(difference{delete face.dataset.hand;delete face._dragAngle;delete face._lastPointerAngle}; + const start=(e,hand)=>{if(S.solved)return;e.preventDefault();e.stopPropagation();face.dataset.hand=hand;if(hand==='hour'&&uses24()){face._dragAngle=S.h*30+S.m/2;face._lastPointerAngle=null}face.setPointerCapture(e.pointerId);move(e)}; + hourHand.onpointerdown=e=>start(e,'hour');minuteHand.onpointerdown=e=>start(e,'minute'); + face.onpointermove=e=>{if(face.hasPointerCapture(e.pointerId))move(e)}; + face.onpointerup=e=>{move(e);face.releasePointerCapture(e.pointerId);clearDrag()}; + face.onpointercancel=e=>{if(face.hasPointerCapture(e.pointerId))face.releasePointerCapture(e.pointerId);clearDrag()} + } + return face + }; const assignment=(hour,minute,compact=false)=>{const wrap=document.createElement('div');wrap.className='clock-assignment'+(compact?' compact':'');if(['analogue','both'].includes(S.style)){const clock=makeClock(hour,minute,false);if(compact)clock.classList.add('clock-reference');wrap.append(clock)}if(['digital','both'].includes(S.style)){const digital=document.createElement('output');digital.className='clock-task-digital';digital.textContent=time(hour,minute);wrap.append(digital)}if(['words','both'].includes(S.style)){const written=document.createElement('p');written.className='clock-task-words';written.textContent=words(hour,minute);wrap.append(written)}if(uses24()&&S.style==='analogue'){const period=document.createElement('span');period.className='clock-daypart';period.textContent=T(daypartKey(hour));wrap.append(period)}return wrap}; - const periodControl=()=>{const control=document.createElement('div');control.className='clock-period';control.setAttribute('aria-label',T('clock24Hour'));[0,12].forEach(period=>{const button=btn(period?'12–23':'00–11',()=>{if(S.solved)return;const dial=clockHour(S.h);S.h=(dial%12)+period;S.feedback='';draw()},'ghost');button.title=period?'12:00–23:59':'00:00–11:59';const active=(S.h>=12?12:0)===period;button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active));control.append(button)});return control}; + const periodControl=()=>{const control=document.createElement('div');control.className='clock-period';control.setAttribute('aria-label',T('clock24Hour'));[0,12].forEach(period=>{const button=btn(period?'12–23':'00–11',()=>{if(S.solved)return;const dial=clockHour(S.h);S.h=(dial%12)+period;S.feedback='';draw()},'ghost');button.dataset.period=period;button.title=period?'12:00–23:59':'00:00–11:59';const active=(S.h>=12?12:0)===period;button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active));control.append(button)});return control}; 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');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.amode==='blocks'){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])}else{if(S.input.length<4){S.feedback='incomplete';sndBad();draw();return}S.h=+S.input.slice(0,2);S.m=+S.input.slice(2,4)}}const expectedHour=S.game==='set'?(uses24()?S.targetH:clockHour(S.targetH)):S.targetH;setFeedback(S.h===expectedHour&&S.m===S.targetM)}; diff --git a/test/widgets.test.js b/test/widgets.test.js index f1278c8..8d80de8 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -95,7 +95,9 @@ test('analoge oefenklok is bedienbaar en houdt opdrachtacties buiten instellinge assert.ok(clockSource.includes("hour24Setting.hidden=S.level!==3")); assert.ok(clockSource.includes("use24.onchange=()=>{S.use24=use24.checked;newTask()}")); assert.ok(clockSource.includes("period?'12–23':'00–11'"), '24-uursmodus laat de juiste daghelft kiezen'); - assert.ok(clockSource.includes('S.h=inputHour(bestH)'), 'de urenwijzer bewaart de gekozen daghelft'); + assert.ok(clockSource.includes('face._dragAngle=S.h*30+S.m/2'), 'de urenwijzer start vanaf de actuele positie binnen 24 uur'); + assert.ok(clockSource.includes('for(let hour=0;hour<24;hour++)'), 'de urenwijzer kan alle 24 uren aanwijzen'); + assert.ok(clockSource.includes('button.dataset.period=period'), 'de daghelft-indicator kan tijdens het draaien live wisselen'); assert.ok(clockSource.includes("const expectedHour=S.game==='set'?(uses24()?S.targetH:clockHour(S.targetH)):S.targetH"), '24-uursantwoorden worden exact gecontroleerd'); assert.ok(clockSource.includes('S.h=uses24()?0:12'), 'een 24-uursopdracht begint met een geldige invoerwaarde'); assert.ok(clockSource.includes("hourHand.onpointerdown=e=>start(e,'hour')")); @@ -107,6 +109,13 @@ test('analoge oefenklok is bedienbaar en houdt opdrachtacties buiten instellinge assert.match(css,/clock-app\{[^}]*justify-content:flex-start/); assert.match(css,/clock-set-answer\{[^}]*align-items:center[^}]*justify-content:center/); assert.match(css,/clock-period \.tbtn\.active\{[^}]*background:var\(--purple\)/); + const context={}; + runInNewContext(source+';globalThis.__clock24={delta:clockTurnDelta,angle:clock24Angle,hour:clock24Hour}',context); + assert.equal(context.__clock24.delta(2,358),4,'vooruit over twaalf uur blijft vooruit draaien'); + assert.equal(context.__clock24.delta(358,2),-4,'achteruit over twaalf uur blijft achteruit draaien'); + assert.equal(context.__clock24.angle(719,2),1,'na 23 uur draait de klok door naar 0 uur'); + assert.equal(context.__clock24.angle(1,-2),719,'achteruit vanaf 0 uur komt de klok bij 23 uur'); + assert.deepEqual([0,11,12,23].map(hour=>context.__clock24.hour(hour*30,0)),[0,11,12,23]); }); test('geldrekenen biedt drie niveaus, leerwijzen, spellen en directe bediening', async()=>{