From 88d1f9cb865737ee6e8656dc7fc769999d7a5bca Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 09:42:45 +0200 Subject: [PATCH] feat: voeg wereldorientatiewidgets toe (v0.3.69-beta) --- VERSION | 2 +- public/css/teach.css | 20 +++++- public/index.html | 1 + public/js/board.js | 5 +- public/js/core.js | 23 ++++-- public/js/widgets/world.js | 143 +++++++++++++++++++++++++++++++++++++ test/widgets.test.js | 44 +++++++++++- 7 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 public/js/widgets/world.js diff --git a/VERSION b/VERSION index 42520d0..852a34b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.68-beta +0.3.69-beta diff --git a/public/css/teach.css b/public/css/teach.css index 77c161f..b4c21a7 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1590,6 +1590,16 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2 .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:10px}.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}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{font-size:36px;height:auto;padding:12px}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:var(--accent-soft);z-index:0}.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}} +/* 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-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.world-top>strong{font-size:15px}.world-top>span{padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900;white-space:nowrap} +.world-task-card{width:min(100%,650px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)}.world-task-card h3{margin:0;text-align:center;font-size:clamp(19px,4cqi,26px)}.world-intro{width:100%;margin:0;padding:9px 12px;border-radius:12px;background:var(--accent-soft);color:var(--accent-ink);font-weight:800;text-align:center} +.world-visual{width:min(100%,330px);display:flex;align-items:center;justify-content:center;gap:10px}.world-visual.mixed{width:min(100%,500px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.world-picture{display:block;width:100%;max-height:230px;object-fit:contain;border-radius:16px;border:1px solid var(--line);background:var(--surface-2)} +.world-answers{width:min(100%,520px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.world-answers .tbtn{min-width:0;min-height:46px;padding:7px 10px}.world-answers .tbtn.correct{background:var(--green);color:#fff;border-color:transparent}.world-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.world-feedback.ok{color:var(--green)}.world-feedback.bad{color:var(--red)}.world-actions{display:flex;justify-content:center}.world-fact{max-width:520px;margin:0;padding:9px 12px;border-radius:12px;background:#eef7e9;color:#2f6f3e;font-weight:800;text-align:center} +.world-gallery{width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.world-specimen{min-width:0;display:flex;flex-direction:column;align-items:center;gap:7px;padding:10px;border:1px solid var(--line);border-radius:16px;background:var(--surface)}.world-specimen .world-visual{width:100%}.world-specimen .world-picture{max-height:160px}.world-specimen h3{margin:0;font-size:17px;text-align:center}.world-specimen p{margin:0;color:var(--muted);font-size:12px;font-weight:700;line-height:1.35;text-align:center} +.topo-card{width:min(100%,700px)}.topo-map-wrap{width:100%;display:flex;justify-content:center;overflow:auto}.topo-map{display:block;width:min(100%,430px);height:auto;max-height:500px;touch-action:manipulation}.topo-water{fill:#e4f4fb}.topo-province{stroke:var(--surface);stroke-width:3;vector-effect:non-scaling-stroke;transition:filter .15s,opacity .15s}.topo-province[role=button]{cursor:pointer}.topo-province[role=button]:hover,.topo-province[role=button]:focus{filter:brightness(.88);outline:none}.topo-province.picked{fill:var(--red)!important}.topo-province.target{fill:var(--green)!important}.topo-label,.topo-city-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:3px;stroke-linejoin:round;font-size:10px;font-weight:950;text-anchor:middle;pointer-events:none}.topo-city-label{text-anchor:start;font-size:9px}.topo-marker{cursor:pointer}.topo-hit{fill:transparent}.topo-dot{fill:var(--navy);stroke:#fff;stroke-width:2}.topo-marker:hover .topo-dot,.topo-marker:focus .topo-dot{fill:var(--orange);outline:none}.topo-marker.picked .topo-dot{fill:var(--red)}.topo-marker.target .topo-dot{fill:var(--green);r:9} +body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .topo-water{fill:#173145}body.dark .topo-label,body.dark .topo-city-label{fill:#f7fbff;stroke:#18324e} + .clock-app{width:100%;min-height:100%;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} .clock-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.clock-level-control{display:flex;align-items:center;gap:6px}.clock-level-control>span{color:var(--muted);font-size:12px;font-weight:900}.clock-levels button{min-width:31px;padding:6px 9px;white-space:nowrap}.clock-mode{width:36px;padding:0;font-size:18px}.clock-score{margin-left:auto;padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900} .clock-task-card{width:min(100%,620px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)} @@ -1729,7 +1739,13 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2 .mood .tbtn{font-size:30px;padding:9px} .poll h3{margin:8px 0} - .money-task-card,.clock-task-card{gap:9px;padding:10px} + .money-task-card,.clock-task-card,.world-task-card{gap:9px;padding:10px} + .world-top{top:-10px;gap:5px} + .world-gallery{grid-template-columns:1fr} + .world-picture{max-height:190px} + .world-specimen .world-picture{max-height:150px} + .topo-map{max-height:450px} + .topo-label,.topo-city-label{font-size:8px} .money-top,.clock-top{top:-10px;gap:5px} .money-level-control>span,.clock-level-control>span{display:none} .money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px} @@ -1760,6 +1776,8 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2 grid-template-columns:repeat(5,34px);gap:4px;padding:7px} .clock-block-tray .clock-drag-block{width:34px;height:52px;min-height:52px} .clock-type-display,.money-type-display{min-width:0;width:100%;font-size:30px} + .world-answers{grid-template-columns:1fr} + .world-visual.mixed{gap:5px} .schedule>div{flex-wrap:wrap} .schedule span{flex-basis:calc(100% - 70px)} .sb-add input{flex-basis:100%} diff --git a/public/index.html b/public/index.html index cbc1ac0..b62e2f3 100644 --- a/public/index.html +++ b/public/index.html @@ -284,6 +284,7 @@ + diff --git a/public/js/board.js b/public/js/board.js index 5b2cd6e..0b057d0 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -46,6 +46,9 @@ const REGISTRY = [ { id:"placevalue", cat:"rekenen", icon:"🔢", w:610, h:390, minW:390, minH:280, mount:mountPlacevalue }, { id:"money", cat:"rekenen", icon:"🪙", w:580, h:560, minW:380, minH:360, mount:mountMoney }, { id:"wordtypes", cat:"taal", icon:"🎨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes }, + { id:"trees", cat:"world", icon:"🌳", w:620, h:600, minW:360, minH:400, mount:mountTrees }, + { id:"birds", cat:"world", icon:"🐦", w:620, h:600, minW:360, minH:400, mount:mountBirds }, + { id:"topography", cat:"world", icon:"🗺️", w:680, h:640, minW:380, minH:440, mount:mountTopography }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, @@ -58,7 +61,7 @@ const REGISTRY = [ { id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; -const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; +const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; /* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord. Op een telefoon is de beschikbare ruimte leidend: een desktop-minimum van bv. diff --git a/public/js/core.js b/public/js/core.js index a5a68f3..499eeff 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* 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. */ -const VERSION = "0.3.65-beta"; +const VERSION = "0.3.69-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -60,7 +60,7 @@ const I18N = { anchorLibEmptyGrid:"Zet eerst woorden in het diagram.", numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —", muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit", - catTaal:"Taal", catRekenen:"Rekenen", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media", + catTaal:"Taal", catRekenen:"Rekenen", catWorld:"Wereldoriëntatie", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media", wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.", wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.", wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.", @@ -119,6 +119,14 @@ const I18N = { wg_clock:"Oefenklok", wg_clock_d:"Leer kloktijden op vier niveaus met meerdere leerwijzen en spellen.", wg_placevalue:"Plaatswaardetabel", wg_placevalue_d:"Ontdek de waarde van cijfers tot honderdduizend.", wg_money:"Geldrekenen", wg_money_d:"Oefen bedragen maken, geld tellen en wisselgeld op drie niveaus.", + wg_trees:"Bomen & bladeren", wg_trees_d:"Leer acht Nederlandse bomen herkennen aan hun vorm en bladeren.", + wg_birds:"Vogels herkennen", wg_birds_d:"Leer acht veelvoorkomende Nederlandse vogels herkennen aan kleur en vorm.", + wg_topography:"Topografie Nederland", wg_topography_d:"Oefen provincies, provinciehoofdsteden en belangrijke steden op de kaart.", + worldActivity:"Werkvorm", worldLearn:"Leren", worldQuiz:"Quiz", worldLevel:"Niveau", worldTreeView:"Wat zie je?", worldTreeWhole:"Hele boom", worldTreeLeaf:"Blad", worldTreeMixed:"Boom en blad", + worldScore:"Score: {score} · reeks: {streak}", worldExploreHint:"Bekijk de kenmerken en vergelijk de soorten.", worldQuestionTree:"Welke boom zie je?", worldQuestionLeaf:"Bij welke boom hoort dit blad?", worldQuestionBird:"Welke vogel zie je?", + worldCorrect:"Goed herkend! 🌟", worldTryAgain:"Kijk nog eens goed naar de kenmerken.", worldNext:"Volgende opdracht", + topoMode:"Oefenen met", topoProvinces:"Provincies", topoCapitals:"Provinciehoofdsteden", topoCities:"Belangrijke steden", topoMapLabel:"Kaart van Nederland", + topoExploreHint:"Bekijk waar de namen op de kaart liggen.", topoFindProvince:"Tik op de provincie {name}.", topoFindCapital:"Waar ligt provinciehoofdstad {name}?", topoFindCity:"Waar ligt {name}?", moneyLearningStyle:"Opdracht tonen", moneyStyleAmount:"Als bedrag", moneyStyleWords:"In woorden", moneyStyleBoth:"Bedrag en woorden", moneyGame:"Spel", moneyGameMake:"Bedrag maken", moneyGameCount:"Geld tellen", moneyGameChange:"Wisselgeld", moneyCategory:"Koopcategorie", moneyCategoryAll:"Alle producten", moneyCategoryFood:"Eten en drinken", moneyCategorySchool:"Schoolspullen", moneyCategoryPlay:"Spelen en sport", @@ -319,7 +327,7 @@ const I18N = { anchorLibEmptyGrid:"Put words in the diagram first.", numWords:"Words per game", all:"All", noThemes:"— no themes yet —", muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off", - catTaal:"Language", catRekenen:"Maths", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media", + catTaal:"Language", catRekenen:"Maths", catWorld:"World studies", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media", wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.", wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.", wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.", @@ -378,6 +386,14 @@ const I18N = { wg_clock:"Practice clock", wg_clock_d:"Learn time at four levels with multiple learning styles and games.", wg_placevalue:"Place value", wg_placevalue_d:"Explore digit values up to hundred thousands.", wg_money:"Money", wg_money_d:"Practise making amounts, counting money and change at three levels.", + wg_trees:"Trees & leaves", wg_trees_d:"Learn to recognise eight common trees by their shape and leaves.", + wg_birds:"Bird identification", wg_birds_d:"Learn to recognise eight common Dutch birds by colour and shape.", + wg_topography:"Map of the Netherlands", wg_topography_d:"Practise provinces, provincial capitals and important cities on the map.", + worldActivity:"Activity", worldLearn:"Learn", worldQuiz:"Quiz", worldLevel:"Level", worldTreeView:"What do you see?", worldTreeWhole:"Whole tree", worldTreeLeaf:"Leaf", worldTreeMixed:"Tree and leaf", + worldScore:"Score: {score} · streak: {streak}", worldExploreHint:"Study the features and compare the species.", worldQuestionTree:"Which tree do you see?", worldQuestionLeaf:"Which tree does this leaf belong to?", worldQuestionBird:"Which bird do you see?", + worldCorrect:"Well recognised! 🌟", worldTryAgain:"Take another close look at the features.", worldNext:"Next task", + topoMode:"Practise", topoProvinces:"Provinces", topoCapitals:"Provincial capitals", topoCities:"Important cities", topoMapLabel:"Map of the Netherlands", + topoExploreHint:"Study where the names belong on the map.", topoFindProvince:"Tap the province {name}.", topoFindCapital:"Where is the provincial capital {name}?", topoFindCity:"Where is {name}?", moneyLearningStyle:"Show task as", moneyStyleAmount:"As an amount", moneyStyleWords:"In words", moneyStyleBoth:"Amount and words", moneyGame:"Game", moneyGameMake:"Make an amount", moneyGameCount:"Count money", moneyGameChange:"Change", moneyCategory:"Shopping category", moneyCategoryAll:"All products", moneyCategoryFood:"Food and drinks", moneyCategorySchool:"School supplies", moneyCategoryPlay:"Play and sports", @@ -1029,4 +1045,3 @@ function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){ const sndGood = ()=> tone(880,0.1); const sndBad = ()=> tone(180,0.18,0,"square",0.10); const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); }; - diff --git a/public/js/widgets/world.js b/public/js/widgets/world.js new file mode 100644 index 0000000..77a43c0 --- /dev/null +++ b/public/js/widgets/world.js @@ -0,0 +1,143 @@ +/* Wereldoriëntatie: natuurherkenning en Nederlandse topografie. */ +const WORLD_TREES=Object.freeze([ + {id:'oak',nl:'Eik',en:'Oak',tier:1,hintNl:'Herkenbaar aan de ronde bladlobben en eikels.',hintEn:'Recognisable by rounded leaf lobes and acorns.'}, + {id:'birch',nl:'Berk',en:'Birch',tier:1,hintNl:'Witte bast en driehoekige blaadjes met een gezaagde rand.',hintEn:'White bark and triangular leaves with a serrated edge.'}, + {id:'horse_chestnut',nl:'Paardenkastanje',en:'Horse chestnut',tier:1,hintNl:'Vijf tot zeven grote blaadjes vormen samen één handvormig blad.',hintEn:'Five to seven large leaflets form one hand-shaped leaf.'}, + {id:'pine',nl:'Grove den',en:'Scots pine',tier:1,hintNl:'Naalden staan per twee bij elkaar en de boom draagt kegels.',hintEn:'Needles grow in pairs and the tree carries cones.'}, + {id:'beech',nl:'Beuk',en:'Beech',tier:2,hintNl:'Gladgrijze stam en ovale blaadjes met een golvende rand.',hintEn:'Smooth grey trunk and oval leaves with a wavy edge.'}, + {id:'maple',nl:'Esdoorn',en:'Maple',tier:2,hintNl:'Puntig handvormig blad en gevleugelde zaden.',hintEn:'Pointed hand-shaped leaf and winged seeds.'}, + {id:'willow',nl:'Wilg',en:'Willow',tier:3,hintNl:'Lange smalle blaadjes aan vaak hangende takken.',hintEn:'Long narrow leaves on often drooping branches.'}, + {id:'alder',nl:'Els',en:'Alder',tier:3,hintNl:'Ronde blaadjes met een ingedeukte top en kleine houtige propjes.',hintEn:'Rounded leaves with a notched tip and small woody cones.'}, +]); + +const WORLD_BIRDS=Object.freeze([ + {id:'robin',nl:'Roodborst',en:'European robin',tier:1,body:'#8a6a4b',head:'#786047',wing:'#6e543e',breast:'#ef6c32',bill:'#33261d',hintNl:'Oranjerode borst en gezicht, met een bruine rug.',hintEn:'Orange-red breast and face with a brown back.'}, + {id:'great_tit',nl:'Koolmees',en:'Great tit',tier:1,body:'#f2cf3f',head:'#18222b',wing:'#4b6670',breast:'#f2cf3f',bill:'#222',hintNl:'Zwarte kop, witte wangen en een zwarte streep over de gele buik.',hintEn:'Black head, white cheeks and a black stripe down the yellow belly.'}, + {id:'blackbird',nl:'Merel',en:'Common blackbird',tier:1,body:'#22262b',head:'#1b1e22',wing:'#30343a',breast:'#25292e',bill:'#f0a521',hintNl:'Het mannetje is zwart met een opvallend gele snavel.',hintEn:'The male is black with a striking yellow bill.'}, + {id:'mallard',nl:'Wilde eend',en:'Mallard',tier:1,body:'#a8a9a2',head:'#14604e',wing:'#766953',breast:'#7b4832',bill:'#e2b43f',hintNl:'Het mannetje heeft een groene kop, witte halsring en bruine borst.',hintEn:'The male has a green head, white neck ring and brown breast.'}, + {id:'blue_tit',nl:'Pimpelmees',en:'Blue tit',tier:2,body:'#f3d85a',head:'#3a78a7',wing:'#39759a',breast:'#f3d85a',bill:'#30343c',hintNl:'Blauw petje en vleugels, witte wangen en een gele buik.',hintEn:'Blue cap and wings, white cheeks and a yellow belly.'}, + {id:'house_sparrow',nl:'Huismus',en:'House sparrow',tier:2,body:'#9a7654',head:'#80654e',wing:'#6e5038',breast:'#b79a7c',bill:'#403329',hintNl:'Bruin gestreepte rug; het mannetje heeft een zwarte bef.',hintEn:'Brown streaked back; the male has a black bib.'}, + {id:'magpie',nl:'Ekster',en:'Eurasian magpie',tier:3,body:'#f4f5f5',head:'#182127',wing:'#172a33',breast:'#f4f5f5',bill:'#20262a',hintNl:'Zwart-wit verenkleed en een opvallend lange donkere staart.',hintEn:'Black-and-white plumage and a strikingly long dark tail.'}, + {id:'woodpecker',nl:'Grote bonte specht',en:'Great spotted woodpecker',tier:3,body:'#f4f1e8',head:'#20262b',wing:'#1f252a',breast:'#eee9dc',bill:'#373a3b',hintNl:'Zwart-wit met een rode vlek onder de staart.',hintEn:'Black and white with a red patch beneath the tail.'}, +]); + +const NL_PROVINCES=Object.freeze([ + {id:'friesland',nl:'Friesland',en:'Friesland',capital:'leeuwarden',tier:2,color:'#7bc8e8',d:'M90 85L95 55 145 38 205 45 235 75 220 115 175 135 125 115Z',x:158,y:82}, + {id:'groningen',nl:'Groningen',en:'Groningen',capital:'groningen_city',tier:2,color:'#ef8d71',d:'M205 45L265 30 330 50 315 90 270 105 235 75Z',x:275,y:67}, + {id:'drenthe',nl:'Drenthe',en:'Drenthe',capital:'assen',tier:3,color:'#e9c66b',d:'M220 115L270 105 310 125 300 180 245 190 220 160Z',x:266,y:145}, + {id:'overijssel',nl:'Overijssel',en:'Overijssel',capital:'zwolle',tier:2,color:'#78c49b',d:'M245 190L300 180 330 210 310 260 260 270 225 235Z',x:282,y:222}, + {id:'flevoland',nl:'Flevoland',en:'Flevoland',capital:'lelystad',tier:3,color:'#b5d969',d:'M150 170L215 160 245 190 225 235 175 245 145 210Z',x:195,y:202}, + {id:'noord_holland',nl:'Noord-Holland',en:'North Holland',capital:'haarlem',tier:1,color:'#f3a9c6',d:'M90 85L125 115 150 170 145 210 165 245 145 285 105 270 85 205 75 145Z',x:112,y:198}, + {id:'utrecht',nl:'Utrecht',en:'Utrecht',capital:'utrecht_city',tier:1,color:'#d0a6df',d:'M145 245L175 245 210 265 200 305 160 305 135 280Z',x:174,y:276}, + {id:'gelderland',nl:'Gelderland',en:'Gelderland',capital:'arnhem',tier:1,color:'#f0aa62',d:'M210 265L260 270 310 260 315 310 275 345 205 335 200 305Z',x:258,y:302}, + {id:'zuid_holland',nl:'Zuid-Holland',en:'South Holland',capital:'den_haag',tier:1,color:'#7fb2e6',d:'M105 270L145 285 160 305 205 335 175 355 115 345 75 315Z',x:132,y:318}, + {id:'zeeland',nl:'Zeeland',en:'Zeeland',capital:'middelburg',tier:3,color:'#86cfbf',d:'M45 326L90 320 112 337 82 349 38 342ZM35 351L78 350 105 365 75 378 30 370ZM55 382L103 378 120 391 82 402 45 395Z',x:73,y:369}, + {id:'noord_brabant',nl:'Noord-Brabant',en:'North Brabant',capital:'den_bosch',tier:1,color:'#e88c91',d:'M115 345L175 355 205 335 275 345 285 385 250 410 175 400 120 380Z',x:202,y:376}, + {id:'limburg',nl:'Limburg',en:'Limburg',capital:'maastricht',tier:1,color:'#e6c85f',d:'M250 410L285 385 300 420 285 450 295 485 278 520 255 490 260 455 242 430Z',x:272,y:458}, +]); + +const NL_CITIES=Object.freeze([ + {id:'leeuwarden',nl:'Leeuwarden',en:'Leeuwarden',province:'friesland',capital:true,tier:2,x:150,y:72}, + {id:'groningen_city',nl:'Groningen',en:'Groningen',province:'groningen',capital:true,tier:2,x:281,y:66}, + {id:'assen',nl:'Assen',en:'Assen',province:'drenthe',capital:true,tier:3,x:264,y:140}, + {id:'zwolle',nl:'Zwolle',en:'Zwolle',province:'overijssel',capital:true,tier:2,x:266,y:218}, + {id:'lelystad',nl:'Lelystad',en:'Lelystad',province:'flevoland',capital:true,tier:3,x:195,y:203}, + {id:'haarlem',nl:'Haarlem',en:'Haarlem',province:'noord_holland',capital:true,tier:1,x:119,y:250}, + {id:'utrecht_city',nl:'Utrecht',en:'Utrecht',province:'utrecht',capital:true,tier:1,x:173,y:278}, + {id:'arnhem',nl:'Arnhem',en:'Arnhem',province:'gelderland',capital:true,tier:1,x:270,y:310}, + {id:'den_haag',nl:'Den Haag',en:'The Hague',province:'zuid_holland',capital:true,tier:1,x:96,y:312}, + {id:'middelburg',nl:'Middelburg',en:'Middelburg',province:'zeeland',capital:true,tier:3,x:69,y:374}, + {id:'den_bosch',nl:"'s-Hertogenbosch",en:"'s-Hertogenbosch",province:'noord_brabant',capital:true,tier:1,x:207,y:369}, + {id:'maastricht',nl:'Maastricht',en:'Maastricht',province:'limburg',capital:true,tier:1,x:275,y:493}, + {id:'amsterdam',nl:'Amsterdam',en:'Amsterdam',province:'noord_holland',capital:false,tier:1,x:139,y:246}, + {id:'rotterdam',nl:'Rotterdam',en:'Rotterdam',province:'zuid_holland',capital:false,tier:1,x:120,y:333}, + {id:'eindhoven',nl:'Eindhoven',en:'Eindhoven',province:'noord_brabant',capital:false,tier:1,x:230,y:395}, + {id:'nijmegen',nl:'Nijmegen',en:'Nijmegen',province:'gelderland',capital:false,tier:1,x:285,y:330}, + {id:'enschede',nl:'Enschede',en:'Enschede',province:'overijssel',capital:false,tier:2,x:315,y:230}, + {id:'alkmaar',nl:'Alkmaar',en:'Alkmaar',province:'noord_holland',capital:false,tier:2,x:105,y:185}, + {id:'breda',nl:'Breda',en:'Breda',province:'noord_brabant',capital:false,tier:3,x:158,y:383}, + {id:'tilburg',nl:'Tilburg',en:'Tilburg',province:'noord_brabant',capital:false,tier:3,x:193,y:391}, +]); + +const worldName=item=>LANG==='nl'?item.nl:item.en; +const worldHint=item=>LANG==='nl'?item.hintNl:item.hintEn; +const worldSvgUrl=svg=>'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg); +const worldShuffle=items=>{const copy=[...items];for(let i=copy.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[copy[i],copy[j]]=[copy[j],copy[i]]}return copy}; +const worldSelect=(value,items)=>{const select=document.createElement('select');items.forEach(([id,key])=>select.add(new Option(T(key),id)));select.value=value;return select}; +const worldLevelPool=(items,level)=>items.filter(item=>item.tier<=level); + +function treeSvg(item,view){ + const crowns={ + oak:'', + birch:'', + horse_chestnut:'', + pine:'', + beech:'', + maple:'', + willow:'', + alder:'', + }; + const leaves={ + oak:'', + birch:'', + horse_chestnut:'', + pine:'', + beech:'', + maple:'', + willow:'', + alder:'', + }; + if(view==='leaf')return worldSvgUrl(`${leaves[item.id]}`); + const trunk=item.id==='birch'?'#f2eee2':'#815939',marks=item.id==='birch'?'':''; + return worldSvgUrl(`${marks}${crowns[item.id]}`); +} + +function birdSvg(item){ + const extras={ + great_tit:'', + blue_tit:'', + house_sparrow:'', + mallard:'', + magpie:'', + woodpecker:'', + robin:'', + blackbird:'', + }[item.id]||''; + const tail=item.id==='magpie'?'M82 111L26 158L101 132Z':'M84 112L45 142L98 130Z'; + return worldSvgUrl(`${extras}`); +} + +function worldPicture(item,type,view){const img=document.createElement('img');img.className='world-picture';img.alt=worldName(item);img.draggable=false;img.src=type==='trees'?treeSvg(item,view):birdSvg(item);return img} +function worldVisual(item,type,view){const visual=document.createElement('div');visual.className='world-visual'+(view==='mixed'?' mixed':'');if(type==='trees'&&view==='mixed')visual.append(worldPicture(item,type,'tree'),worldPicture(item,type,'leaf'));else visual.append(worldPicture(item,type,view));return visual} + +function mountWorldQuiz(root,init,config){ + const S=Object.assign({activity:'quiz',level:1,view:config.type==='trees'?'mixed':'bird',score:0,streak:0,target:'',taskView:'',options:[],solved:false,feedback:''},init||{});S.level=Math.max(1,Math.min(3,+S.level||1)); + const activity=worldSelect(S.activity,[['learn','worldLearn'],['quiz','worldQuiz']]),level=worldSelect(String(S.level),[['1','lvl1'],['2','lvl2'],['3','lvl3']]),stage=document.createElement('div'),settings=[field(T('worldActivity'),activity),field(T('worldLevel'),level)]; + let view;if(config.type==='trees'){view=worldSelect(S.view,[['tree','worldTreeWhole'],['leaf','worldTreeLeaf'],['mixed','worldTreeMixed']]);settings.push(field(T('worldTreeView'),view))} + const pool=()=>worldLevelPool(config.items,S.level),current=()=>config.items.find(item=>item.id===S.target)||pool()[0]; + const newTask=()=>{const items=pool(),previous=S.target;let target=items[Math.floor(Math.random()*items.length)];if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)];S.target=target.id;S.taskView=config.type==='trees'?(S.view==='mixed'?(Math.random()<.5?'tree':'leaf'):S.view):'bird';S.options=worldShuffle([target,...worldShuffle(items.filter(item=>item.id!==target.id)).slice(0,3)]).map(item=>item.id);S.solved=false;S.feedback='';draw()}; + const answer=id=>{if(S.solved)return;if(id===S.target){S.solved=true;S.feedback='good';S.score++;S.streak++;sndGood()}else{S.feedback='bad';S.streak=0;sndBad()}draw()}; + const gallery=()=>{const grid=document.createElement('div');grid.className='world-gallery';pool().forEach(item=>{const card=document.createElement('article');card.className='world-specimen';card.append(worldVisual(item,config.type,config.type==='trees'?S.view:'bird'));const name=document.createElement('h3');name.textContent=worldName(item);const hint=document.createElement('p');hint.textContent=worldHint(item);card.append(name,hint);grid.append(card)});return grid}; + function draw(){S.activity=activity.value;S.level=+level.value;if(view)S.view=view.value;stage.className='world-app';stage.innerHTML='';const top=document.createElement('div');top.className='world-top';const title=document.createElement('strong');title.textContent=T('wg_'+config.type);const score=document.createElement('span');score.textContent=T('worldScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(title,score);stage.append(top);if(S.activity==='learn'){const intro=document.createElement('p');intro.className='world-intro';intro.textContent=T('worldExploreHint');stage.append(intro,gallery());return}const item=current(),card=document.createElement('section');card.className='world-task-card';const question=document.createElement('h3');question.textContent=T(config.type==='birds'?'worldQuestionBird':S.taskView==='leaf'?'worldQuestionLeaf':'worldQuestionTree');card.append(question,worldVisual(item,config.type,S.taskView));const answers=document.createElement('div');answers.className='world-answers';S.options.forEach(id=>{const option=config.items.find(entry=>entry.id===id);if(!option)return;const button=btn(worldName(option),()=>answer(id));if(S.solved&&id===S.target)button.classList.add('correct');answers.append(button)});card.append(answers);const feedback=document.createElement('p');feedback.className='world-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'worldCorrect':'worldTryAgain');card.append(feedback);const actions=document.createElement('div');actions.className='world-actions';actions.append(btn(T('worldNext'),newTask,'ghost'));card.append(actions);if(S.solved){const fact=document.createElement('p');fact.className='world-fact';fact.textContent=worldHint(item);card.append(fact)}stage.append(card)} + activity.onchange=()=>{S.activity=activity.value;if(S.activity==='quiz')newTask();else draw()};level.onchange=()=>{S.level=+level.value;if(S.activity==='quiz')newTask();else draw()};if(view)view.onchange=()=>{S.view=view.value;if(S.activity==='quiz')newTask();else draw()};learningShell(root,settings,stage);if(S.activity==='quiz')newTask();else draw();return{getState:()=>({...S,options:[...S.options]})}; +} +function mountTrees(root,init){return mountWorldQuiz(root,init,{type:'trees',items:WORLD_TREES})} +function mountBirds(root,init){return mountWorldQuiz(root,init,{type:'birds',items:WORLD_BIRDS})} + +const SVG_NS='http://www.w3.org/2000/svg'; +function svgNode(name,attributes={}){const node=document.createElementNS(SVG_NS,name);Object.entries(attributes).forEach(([key,value])=>node.setAttribute(key,value));return node} +function svgAction(node,label,action){node.setAttribute('role','button');node.setAttribute('tabindex','0');node.setAttribute('aria-label',label);node.addEventListener('click',action);node.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();action()}})} +function makeTopoMap(mode,pool,onPick,showLabels,picked,target){ + const wrap=document.createElement('div');wrap.className='topo-map-wrap';const svg=svgNode('svg',{class:'topo-map',viewBox:'20 20 330 510',role:'img','aria-label':T('topoMapLabel')});svg.append(svgNode('rect',{x:20,y:20,width:330,height:510,rx:22,class:'topo-water'})); + NL_PROVINCES.forEach(province=>{const path=svgNode('path',{d:province.d,fill:province.color,class:'topo-province'+(province.id===target?' target':'')+(province.id===picked?' picked':''),'data-id':province.id});const title=svgNode('title');title.textContent=worldName(province);path.append(title);if(onPick&&mode==='provinces')svgAction(path,worldName(province),()=>onPick(province.id));svg.append(path);if(showLabels&&mode==='provinces'&&pool.some(item=>item.id===province.id)){const label=svgNode('text',{x:province.x,y:province.y,class:'topo-label'});label.textContent=worldName(province);svg.append(label)}}); + if(mode!=='provinces')pool.forEach(city=>{const group=svgNode('g',{class:'topo-marker'+(city.id===target?' target':'')+(city.id===picked?' picked':'')});const hit=svgNode('circle',{cx:city.x,cy:city.y,r:15,class:'topo-hit'}),dot=svgNode('circle',{cx:city.x,cy:city.y,r:6,class:'topo-dot'});group.append(hit,dot);if(showLabels){const label=svgNode('text',{x:city.x+9,y:city.y-8,class:'topo-city-label'});label.textContent=worldName(city);group.append(label)}if(onPick)svgAction(group,worldName(city),()=>onPick(city.id));svg.append(group)});wrap.append(svg);return wrap; +} +function mountTopography(root,init){ + const S=Object.assign({activity:'quiz',mode:'provinces',level:1,score:0,streak:0,target:'',picked:'',solved:false,feedback:''},init||{});S.level=Math.max(1,Math.min(3,+S.level||1)); + const activity=worldSelect(S.activity,[['learn','worldLearn'],['quiz','worldQuiz']]),mode=worldSelect(S.mode,[['provinces','topoProvinces'],['capitals','topoCapitals'],['cities','topoCities']]),level=worldSelect(String(S.level),[['1','lvl1'],['2','lvl2'],['3','lvl3']]),stage=document.createElement('div'); + const pool=()=>S.mode==='provinces'?NL_PROVINCES.filter(item=>item.tier<=S.level):S.mode==='capitals'?NL_CITIES.filter(item=>item.capital&&item.tier<=S.level):NL_CITIES.filter(item=>!item.capital&&item.tier<=S.level),current=()=>pool().find(item=>item.id===S.target)||pool()[0]; + const newTask=()=>{const items=pool(),previous=S.target;let target=items[Math.floor(Math.random()*items.length)];if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)];S.target=target.id;S.picked='';S.solved=false;S.feedback='';draw()}; + const answer=id=>{if(S.solved)return;S.picked=id;if(id===S.target){S.solved=true;S.feedback='good';S.score++;S.streak++;sndGood()}else{S.feedback='bad';S.streak=0;sndBad()}draw()}; + function draw(){S.activity=activity.value;S.mode=mode.value;S.level=+level.value;stage.className='world-app topo-app';stage.innerHTML='';const top=document.createElement('div');top.className='world-top';const title=document.createElement('strong');title.textContent=T('wg_topography');const score=document.createElement('span');score.textContent=T('worldScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(title,score);stage.append(top);const card=document.createElement('section');card.className='world-task-card topo-card';if(S.activity==='learn'){const intro=document.createElement('p');intro.className='world-intro';intro.textContent=T('topoExploreHint');card.append(intro,makeTopoMap(S.mode,pool(),null,true,'',''))}else{const item=current(),question=document.createElement('h3');question.textContent=T(S.mode==='provinces'?'topoFindProvince':S.mode==='capitals'?'topoFindCapital':'topoFindCity').replace('{name}',worldName(item));card.append(question,makeTopoMap(S.mode,pool(),answer,S.solved,S.picked,S.solved?S.target:''));const feedback=document.createElement('p');feedback.className='world-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'worldCorrect':'worldTryAgain');const actions=document.createElement('div');actions.className='world-actions';actions.append(btn(T('worldNext'),newTask,'ghost'));card.append(feedback,actions)}stage.append(card)} + activity.onchange=()=>{S.activity=activity.value;if(S.activity==='quiz')newTask();else draw()};mode.onchange=()=>{S.mode=mode.value;if(S.activity==='quiz')newTask();else draw()};level.onchange=()=>{S.level=+level.value;if(S.activity==='quiz')newTask();else draw()};learningShell(root,[field(T('worldActivity'),activity),field(T('topoMode'),mode),field(T('worldLevel'),level)],stage);if(S.activity==='quiz')newTask();else draw();return{getState:()=>({...S})}; +} diff --git a/test/widgets.test.js b/test/widgets.test.js index 2fb4d5a..d939ce1 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -5,6 +5,7 @@ import {runInNewContext} from 'node:vm'; const ids=['birthday','numberline','sentence']; const learningIds=['fractions','clock','placevalue','money','wordtypes','schedule','mood','poll']; +const worldIds=['trees','birds','topography']; test('nieuwe widgets zijn geladen vóór het widgetregister', async()=>{ const html=await readFile('public/index.html','utf8'); @@ -54,6 +55,43 @@ test('nieuwe widgets zijn tweetalig beschreven', async()=>{ } }); +test('wereldoriëntatiewidgets laden, registreren en vertalen', async()=>{ + const [html,board,source,core]=await Promise.all([readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'),readFile('public/js/widgets/world.js','utf8'),readFile('public/js/core.js','utf8')]); + assert.ok(html.indexOf('js/widgets/world.js'){ + const source=await readFile('public/js/widgets/world.js','utf8'),context={}; + runInNewContext(source+';globalThis.__world={trees:WORLD_TREES,birds:WORLD_BIRDS,provinces:NL_PROVINCES,cities:NL_CITIES}',context); + const data=JSON.parse(JSON.stringify(context.__world)); + assert.equal(data.trees.length,8); + assert.equal(data.birds.length,8); + assert.equal(data.provinces.length,12); + assert.equal(data.cities.filter(city=>city.capital).length,12); + assert.equal(data.cities.filter(city=>!city.capital).length,8); + for(const collection of [data.trees,data.birds,data.provinces,data.cities])assert.equal(new Set(collection.map(item=>item.id)).size,collection.length); + for(const item of [...data.trees,...data.birds])assert.ok(item.nl&&item.en&&item.hintNl&&item.hintEn&&item.tier>=1&&item.tier<=3,item.id); + const cities=new Map(data.cities.map(city=>[city.id,city])); + const capitals=Object.fromEntries(data.provinces.map(province=>[province.nl,cities.get(province.capital)?.nl])); + assert.deepEqual(capitals,{ + Friesland:'Leeuwarden',Groningen:'Groningen',Drenthe:'Assen',Overijssel:'Zwolle',Flevoland:'Lelystad','Noord-Holland':'Haarlem',Utrecht:'Utrecht',Gelderland:'Arnhem','Zuid-Holland':'Den Haag',Zeeland:'Middelburg','Noord-Brabant':"'s-Hertogenbosch",Limburg:'Maastricht', + }); + for(const province of data.provinces)assert.ok(province.d&&Number.isFinite(province.x)&&Number.isFinite(province.y)&&cities.has(province.capital),province.id); +}); + test('widgetbibliotheek bevat zoeken favorieten sjablonen en borddata-acties', async()=>{ const [html,board]=await Promise.all([readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8')]); for(const id of ['gallerySearch','galleryFavOnly','galleryTemplates','boardUndo','boardRedo','boardExport','boardImport'])assert.match(html,new RegExp('id="'+id+'"')); @@ -231,6 +269,9 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- msub:'.mt-pad', mmul:'.mt-pad', mdiv:'.mt-pad', + trees:'.world-gallery', + birds:'.world-picture', + topography:'.topo-map', mpct:'.mt-pad', numberline:'.nlw-settings', fractions:'.fraction-wall', @@ -267,7 +308,8 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- '.bd{grid-template-columns:1fr}', '.sb-banks{grid-template-columns:1fr}', '.edu-stage{align-items:safe center;justify-content:safe center', - '.money-task-card,.clock-task-card', + '.money-task-card,.clock-task-card,.world-task-card', + '.world-gallery{grid-template-columns:1fr}', ]){ assert.ok(compact.includes(selector),selector+' mist in de compacte containerregels'); }