Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
10 changed files with 428 additions and 2 deletions
Showing only changes of commit 9920fd3855 - Show all commits

View file

@ -1 +1 @@
0.4.20-beta 0.4.21-beta

View file

@ -1722,6 +1722,61 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2
.wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px} .wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px}
.schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:12px;border-left:4px solid var(--widget-accent)}.schedule-icon{width:33px;height:33px;display:grid;place-items:center;flex:none;border-radius:10px;background:var(--surface);font-size:20px;font-style:normal}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.class-total{flex-basis:100%;width:100%;margin:0 0 3px;color:var(--muted);font-size:12px;font-weight:900;text-align:center}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{flex:1;min-width:62px;font-size:36px;height:auto;padding:12px;background:var(--surface);border:2px solid var(--line);color:var(--ink)}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{margin-bottom:0;text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:color-mix(in srgb,var(--widget-accent) 22%,var(--surface));z-index:0;transition:width .25s}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate} .schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:12px;border-left:4px solid var(--widget-accent)}.schedule-icon{width:33px;height:33px;display:grid;place-items:center;flex:none;border-radius:10px;background:var(--surface);font-size:20px;font-style:normal}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.class-total{flex-basis:100%;width:100%;margin:0 0 3px;color:var(--muted);font-size:12px;font-weight:900;text-align:center}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{flex:1;min-width:62px;font-size:36px;height:auto;padding:12px;background:var(--surface);border:2px solid var(--line);color:var(--ink)}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{margin-bottom:0;text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:color-mix(in srgb,var(--widget-accent) 22%,var(--surface));z-index:0;transition:width .25s}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate}
@media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}} @media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}}
/* =========== Geluidsmeter =========== */
.nm{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:14px}
.nm-stage{display:flex;align-items:flex-end;gap:14px;flex:1;min-height:0}
.nm-gauge{position:relative;width:70px;height:min(280px,100%);min-height:120px;border-radius:35px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden;box-shadow:inset 0 2px 8px rgba(20,36,52,.12)}
.nm-fill{position:absolute;left:0;right:0;bottom:0;height:0%;border-radius:35px 35px 0 0;transition:height .25s ease-out,background-color .25s;background:var(--surface-2)}
.nm-fill.green{background:var(--green)}
.nm-fill.yellow{background:var(--yellow)}
.nm-fill.red{background:var(--red)}
.nm-mark{position:absolute;left:0;right:0;height:2px;background:rgba(255,255,255,.8);box-shadow:0 0 0 1px rgba(20,36,52,.25)}
.nm-face{font-size:52px;line-height:1}
.nm-gauge.warn{animation:nmwarn 1s infinite}
@keyframes nmwarn{0%,100%{box-shadow:inset 0 2px 8px rgba(20,36,52,.12)}50%{box-shadow:inset 0 2px 8px rgba(20,36,52,.12),0 0 0 4px color-mix(in srgb,var(--red) 45%,transparent)}}
.nm-status{min-height:26px;font-size:16px;font-weight:900;text-align:center}
.nm-zones{width:100%;max-width:320px;display:flex;flex-direction:column;gap:6px}
.nm-zone-row{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:800;color:var(--muted)}
.nm-zone-row input{flex:1}
.nm-zone-lbl{flex:none;min-width:96px}
.nm-privacy{margin:0;max-width:320px;color:var(--muted);font-size:11px;text-align:center;line-height:1.4}
/* =========== Gevoelszones =========== */
.fz{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;padding:14px;overflow:auto}
.fz-zones{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.fz-zone{display:flex;flex-direction:column;align-items:center;gap:6px;padding:14px 10px;border-radius:18px;border:2px solid var(--line);background:var(--surface);cursor:pointer;font:inherit;color:var(--ink)}
.fz-zone.blue{border-color:color-mix(in srgb,var(--blue) 40%,var(--line))}
.fz-zone.green{border-color:color-mix(in srgb,var(--green) 40%,var(--line))}
.fz-zone.yellow{border-color:color-mix(in srgb,var(--yellow) 55%,var(--line))}
.fz-zone.red{border-color:color-mix(in srgb,var(--red) 40%,var(--line))}
.fz-zone.active.blue{background:color-mix(in srgb,var(--blue) 16%,var(--surface))}
.fz-zone.active.green{background:color-mix(in srgb,var(--green) 16%,var(--surface))}
.fz-zone.active.yellow{background:color-mix(in srgb,var(--yellow) 22%,var(--surface))}
.fz-zone.active.red{background:color-mix(in srgb,var(--red) 16%,var(--surface))}
.fz-emoji{font-size:32px}
.fz-label{font-weight:900;font-size:14px}
.fz-detail{padding:12px 16px;border-radius:16px;border:1px solid var(--line);background:var(--surface-2);text-align:center}
.fz-detail-title{margin:0 0 4px;font-size:16px}
.fz-detail-desc{margin:0 0 6px;font-weight:800;color:var(--ink)}
.fz-detail-tip{margin:0;color:var(--muted);font-size:13px;line-height:1.4}
.fz-detail.blue{border-color:color-mix(in srgb,var(--blue) 35%,var(--line))}
.fz-detail.green{border-color:color-mix(in srgb,var(--green) 35%,var(--line))}
.fz-detail.yellow{border-color:color-mix(in srgb,var(--yellow) 45%,var(--line))}
.fz-detail.red{border-color:color-mix(in srgb,var(--red) 35%,var(--line))}
/* =========== Ademhalingsoefening =========== */
.bre{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:14px}
.bre-stage{flex:1;min-height:0;width:100%;display:flex;align-items:center;justify-content:center}
.bre-circle{width:120px;height:120px;border-radius:50%;background:radial-gradient(circle at 35% 30%,color-mix(in srgb,var(--blue) 55%,#fff),var(--blue));display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:#fff;font-weight:900;transform:scale(1);transition:transform 1s ease-in-out;box-shadow:0 12px 30px rgba(20,54,97,.28)}
.bre-circle.hold{filter:brightness(.92)}
.bre-word{font-size:15px}
.bre-count{font-size:26px;font-variant-numeric:tabular-nums}
.bre-rounds{color:var(--muted);font-size:13px;font-weight:800}
.bre-row{display:flex;gap:10px;align-items:center}
/* =========== Complimentenmachine =========== */
.cpl{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:14px}
.cpl-card{width:min(100%,420px);min-height:140px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:20px;border-radius:22px;background:linear-gradient(135deg,var(--pink),var(--purple));color:#fff;text-align:center;box-shadow:0 14px 30px rgba(139,92,246,.28);opacity:.55;transform:scale(.96);transition:opacity .2s,transform .2s}
.cpl-card.show{opacity:1;transform:scale(1)}
.cpl-icon{font-size:34px}
.cpl-text{margin:0;font-size:17px;font-weight:800;line-height:1.4;text-shadow:0 2px 6px rgba(0,0,0,.2)}
/* Wereldoriëntatie: natuurherkenning en topografie */ /* Wereldoriëntatie: natuurherkenning en topografie */
.world-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} .world-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)}

View file

@ -35,6 +35,10 @@
<script src="js/widgets/names.js?v=__V__" defer></script> <script src="js/widgets/names.js?v=__V__" defer></script>
<script src="js/widgets/mind.js?v=__V__" defer></script> <script src="js/widgets/mind.js?v=__V__" defer></script>
<script src="js/widgets/birthday.js?v=__V__" defer></script> <script src="js/widgets/birthday.js?v=__V__" defer></script>
<script src="js/widgets/noise.js?v=__V__" defer></script>
<script src="js/widgets/feelings.js?v=__V__" defer></script>
<script src="js/widgets/breathe.js?v=__V__" defer></script>
<script src="js/widgets/compliments.js?v=__V__" defer></script>
<script src="js/widgets/numberline.js?v=__V__" defer></script> <script src="js/widgets/numberline.js?v=__V__" defer></script>
<script src="js/widgets/sentence.js?v=__V__" defer></script> <script src="js/widgets/sentence.js?v=__V__" defer></script>
<script src="js/widgets/learning.js?v=__V__" defer></script> <script src="js/widgets/learning.js?v=__V__" defer></script>

View file

@ -61,6 +61,10 @@ const REGISTRY = [
{ id:"light", cat:"class", icon:"🚦", w:480, h:440, minW:320, minH:320, mount:mountLight }, { id:"light", cat:"class", icon:"🚦", w:480, h:440, minW:320, minH:320, mount:mountLight },
{ id:"mood", cat:"class", icon:"🌡️", w:530, h:360, minW:350, minH:260, mount:mountMood }, { id:"mood", cat:"class", icon:"🌡️", w:530, h:360, minW:350, minH:260, mount:mountMood },
{ id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll }, { id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll },
{ id:"noise", cat:"class", icon:"🎙️", w:420, h:600, minW:320, minH:460, mount:mountNoise },
{ id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings },
{ id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe },
{ id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments },
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
]; ];
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]];

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.20-beta"; const VERSION = "0.4.21-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -148,6 +148,20 @@ const I18N = {
wg_schedule:"Dagplanning", wg_schedule_d:"Maak een duidelijke visuele planning voor de klas.", wg_schedule:"Dagplanning", wg_schedule_d:"Maak een duidelijke visuele planning voor de klas.",
wg_mood:"Gevoelsmeter", wg_mood_d:"Doe een snelle, anonieme check-in met de klas.", wg_mood:"Gevoelsmeter", wg_mood_d:"Doe een snelle, anonieme check-in met de klas.",
wg_poll:"Klaspoll", wg_poll_d:"Stem samen op maximaal zes antwoorden.", wg_poll:"Klaspoll", wg_poll_d:"Stem samen op maximaal zes antwoorden.",
wg_noise:"Geluidsmeter", wg_noise_d:"Meet het geluidsniveau in de klas met de microfoon - alles blijft lokaal, er wordt niets opgenomen of verstuurd.",
noiseOff:"Zet de microfoon aan om te starten.", noiseStatus_green:"Lekker rustig!", noiseStatus_yellow:"Een beetje luidruchtig…", noiseStatus_red:"Te luid! Even rustig aan.",
noiseDenied:"Kan niet bij de microfoon. Geef toestemming in de browser en probeer opnieuw.",
noiseStart:"Microfoon aan", noiseStop:"Stop", noiseZoneGreen:"Rustig tot", noiseZoneYellow:"Te luid vanaf",
noisePrivacy:"Er wordt niets opgenomen of verstuurd: het geluidsniveau blijft alleen in deze browser.",
wg_feelings:"Gevoelszones", wg_feelings_d:"Individuele check-in: kies je zone en krijg een passende tip om jezelf te helpen.",
fzZone_blue:"Blauw", fzZone_green:"Groen", fzZone_yellow:"Geel", fzZone_red:"Rood",
fzDesc_blue:"Ik voel me moe of verdrietig.", fzDesc_green:"Ik voel me kalm en klaar om te leren.", fzDesc_yellow:"Ik voel me druk, gestrest of opgewonden.", fzDesc_red:"Ik voel me boos of overprikkeld.",
fzTip_blue:"Even rustig zitten, wat water drinken of een boek pakken kan helpen.", fzTip_green:"Fijn zo! Dit is een goed moment om aan de slag te gaan.", fzTip_yellow:"Diep ademhalen of een korte pauze kan helpen om weer rustig te worden.", fzTip_red:"Ga naar de rustige plek, haal diep adem of vraag de juf of meester om hulp.",
wg_breathe:"Ademhalingsoefening", wg_breathe_d:"Adem rustig mee met de groeiende en krimpende cirkel, in drie tempo's.",
bre_in:"Adem in", bre_hold:"Houd vast", bre_out:"Adem uit", breStart:"Start", breStop:"Stop", breRounds:"{n} rondjes gedaan",
"brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast",
wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.",
complimentsDraw:"Trek een compliment",
clock24Hour:"24-uursklok", clock24Hour:"24-uursklok",
widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies Nieuwe opdracht om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies Nieuwe opdracht om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.", wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.",
@ -457,6 +471,20 @@ const I18N = {
wg_schedule:"Day schedule", wg_schedule_d:"Create a clear visual class schedule.", wg_schedule:"Day schedule", wg_schedule_d:"Create a clear visual class schedule.",
wg_mood:"Mood check-in", wg_mood_d:"Run a quick anonymous class check-in.", wg_mood:"Mood check-in", wg_mood_d:"Run a quick anonymous class check-in.",
wg_poll:"Class poll", wg_poll_d:"Vote together on up to six answers.", wg_poll:"Class poll", wg_poll_d:"Vote together on up to six answers.",
wg_noise:"Noise meter", wg_noise_d:"Measure the classroom noise level with the microphone - everything stays local, nothing is recorded or sent anywhere.",
noiseOff:"Turn on the microphone to start.", noiseStatus_green:"Nice and quiet!", noiseStatus_yellow:"A bit noisy…", noiseStatus_red:"Too loud! Let's settle down.",
noiseDenied:"Can't reach the microphone. Allow access in your browser and try again.",
noiseStart:"Microphone on", noiseStop:"Stop", noiseZoneGreen:"Quiet up to", noiseZoneYellow:"Too loud from",
noisePrivacy:"Nothing is recorded or sent anywhere: the noise level stays in this browser only.",
wg_feelings:"Feelings zones", wg_feelings_d:"Individual check-in: pick your zone and get a matching tip to help yourself.",
fzZone_blue:"Blue", fzZone_green:"Green", fzZone_yellow:"Yellow", fzZone_red:"Red",
fzDesc_blue:"I feel tired or sad.", fzDesc_green:"I feel calm and ready to learn.", fzDesc_yellow:"I feel restless, stressed or excited.", fzDesc_red:"I feel angry or overwhelmed.",
fzTip_blue:"Sitting quietly, drinking some water, or grabbing a book can help.", fzTip_green:"Great! This is a good moment to get started.", fzTip_yellow:"Taking a deep breath or a short break can help you feel calm again.", fzTip_red:"Go to the quiet spot, take a deep breath, or ask your teacher for help.",
wg_breathe:"Breathing exercise", wg_breathe_d:"Breathe along with the growing and shrinking circle, in three paces.",
bre_in:"Breathe in", bre_hold:"Hold", bre_out:"Breathe out", breStart:"Start", breStop:"Stop", breRounds:"{n} rounds done",
"brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold",
wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.",
complimentsDraw:"Draw a compliment",
clock24Hour:"24-hour clock", clock24Hour:"24-hour clock",
widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose New task to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose New task to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",
wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.", wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.",

View file

@ -0,0 +1,81 @@
/* 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 }) };
}

View file

@ -0,0 +1,66 @@
/* widget: Complimentenmachine */
/* =========================================================
Trek een willekeurig compliment - zelfde spin-en-onthul-mechaniek als
de namenkiezer (names.js)
==========================================================*/
const COMPLIMENTS = Object.freeze([
{ nl:"Jij bent een fijne klasgenoot!", en:"You're a great classmate!" },
{ nl:"Wat knap dat je dat zelf hebt uitgezocht!", en:"Great job figuring that out yourself!" },
{ nl:"Jij luistert altijd goed naar anderen.", en:"You're always a great listener." },
{ nl:"Je hebt vandaag hard gewerkt, goed gedaan!", en:"You worked hard today, well done!" },
{ nl:"Jij deelt graag met anderen.", en:"You love sharing with others." },
{ nl:"Wat fijn dat jij er bent!", en:"We're so glad you're here!" },
{ nl:"Jij bent heel behulpzaam.", en:"You're very helpful." },
{ nl:"Je hebt een mooie glimlach.", en:"You have a lovely smile." },
{ nl:"Jij geeft nooit op, dat is knap!", en:"You never give up, that's impressive!" },
{ nl:"Jij bent een echte teamspeler.", en:"You're a real team player." },
{ nl:"Wat een goed idee had jij daar!", en:"That was a great idea you had!" },
{ nl:"Jij maakt anderen graag blij.", en:"You love making others happy." },
{ nl:"Je hebt heel goed je best gedaan.", en:"You tried really hard." },
{ nl:"Jij bent creatief en origineel.", en:"You're creative and original." },
{ nl:"Jij zegt altijd eerlijk wat je denkt.", en:"You always speak your mind honestly." },
{ nl:"Jij bent een fijne vriend(in).", en:"You're a great friend." },
{ nl:"Wat kun jij goed samenwerken!", en:"You're so good at working together!" },
{ nl:"Jij maakt de klas een fijnere plek.", en:"You make our class a nicer place." },
{ nl:"Jij durft dingen te proberen, goed zo!", en:"You're brave enough to try new things!" },
{ nl:"Jij bent trots op jezelf, en dat mag!", en:"You're proud of yourself, and that's great!" },
]);
function mountCompliments(root, initState){
let current = null, spinning = false;
root.innerHTML = `
<div class="cpl">
<p class="play-mission cpl-mission"></p>
<div class="cpl-card"><span class="cpl-icon">💝</span><p class="cpl-text"></p></div>
<button class="tbtn cpl-draw"></button>
</div>`;
const card = root.querySelector(".cpl-card"), text = root.querySelector(".cpl-text"), drawBtn = root.querySelector(".cpl-draw");
function pickRandom(){ return COMPLIMENTS[Math.floor(Math.random() * COMPLIMENTS.length)]; }
function draw(){
if(spinning) return;
spinning = true;
card.classList.remove("show");
let n = 0;
const iv = setInterval(()=>{
text.textContent = pickRandom()[LANG];
tone(500 + Math.random() * 200, 0.03, 0, "square", 0.04);
if(++n >= 10){
clearInterval(iv);
current = pickRandom();
text.textContent = current[LANG];
card.classList.add("show");
confetti(card);
sndWin();
spinning = false;
}
}, 70);
}
drawBtn.addEventListener("click", draw);
function labels(){
root.querySelector(".cpl-mission").textContent = T("wg_compliments_d");
drawBtn.textContent = "💝 " + T("complimentsDraw");
if(current) text.textContent = current[LANG];
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({}) };
}

View file

@ -0,0 +1,60 @@
/* widget: Gevoelszones */
/* =========================================================
Individual check-in (Zones of Regulation-stijl): kies je zone, krijg een
passende tip - anders dan de Gevoelsmeter (mood.js), die is een anonieme
klaspoll, geen persoonlijke check-in met uitleg.
==========================================================*/
function mountFeelings(root, initState){
let picked = (initState && initState.picked) || null;
const ZONES = ["blue","green","yellow","red"];
const EMOJI = { blue:"💧", green:"🌿", yellow:"⚡", red:"🔥" };
root.innerHTML = `
<div class="fz">
<p class="play-mission fz-mission"></p>
<div class="fz-zones"></div>
<div class="fz-detail" hidden>
<h3 class="fz-detail-title"></h3>
<p class="fz-detail-desc"></p>
<p class="fz-detail-tip"></p>
</div>
</div>`;
const zonesEl = root.querySelector(".fz-zones"), detail = root.querySelector(".fz-detail");
function pick(id){
picked = id;
tone(id==="blue"?294:id==="green"?392:id==="yellow"?523:220, 0.14, 0, "sine", .08);
draw();
}
function draw(){
zonesEl.innerHTML = "";
ZONES.forEach(id=>{
const b = document.createElement("button");
b.className = "fz-zone " + id + (picked === id ? " active" : "");
b.setAttribute("aria-pressed", String(picked === id));
const emoji = document.createElement("span");
emoji.className = "fz-emoji";
emoji.textContent = EMOJI[id];
const label = document.createElement("span");
label.className = "fz-label";
label.textContent = T("fzZone_" + id);
b.append(emoji, label);
b.addEventListener("click", ()=>pick(id));
zonesEl.appendChild(b);
});
if(picked){
detail.hidden = false;
detail.className = "fz-detail " + picked;
detail.querySelector(".fz-detail-title").textContent = EMOJI[picked] + " " + T("fzZone_" + picked);
detail.querySelector(".fz-detail-desc").textContent = T("fzDesc_" + picked);
detail.querySelector(".fz-detail-tip").textContent = T("fzTip_" + picked);
}else{
detail.hidden = true;
}
}
function labels(){
root.querySelector(".fz-mission").textContent = T("wg_feelings_d");
draw();
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({ picked }) };
}

124
public/js/widgets/noise.js Normal file
View file

@ -0,0 +1,124 @@
/* 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>
<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>`;
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");
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 }) };
}

View file

@ -344,6 +344,10 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
light:'.tl-house', light:'.tl-house',
mood:'.mood', mood:'.mood',
poll:'.poll', poll:'.poll',
noise:'.nm-gauge',
feelings:'.fz-zones',
breathe:'.bre-circle',
compliments:'.cpl-card',
media:'.media-bar', media:'.media-bar',
}; };
assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit'); assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');