From 93c287979c46b84e24acf04d318aaa39e5a59fc3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 11:44:30 +0200 Subject: [PATCH] feat: breid wereldorientatie-oefeningen uit (v0.3.71-beta) --- VERSION | 2 +- public/css/teach.css | 15 +- public/img/world/ATTRIBUTION.md | 12 + public/img/world/world-continents.svg | 31 +++ public/js/core.js | 36 +-- public/js/widgets/world.js | 307 ++++++++++++++++++++++---- test/widgets.test.js | 36 ++- 7 files changed, 373 insertions(+), 66 deletions(-) create mode 100644 public/img/world/world-continents.svg diff --git a/VERSION b/VERSION index d85ea03..2c8f5ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.70-beta +0.3.71-beta diff --git a/public/css/teach.css b/public/css/teach.css index aeb4ea8..efe0c61 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1597,9 +1597,15 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2 .world-visual{width:min(100%,460px);display:flex;align-items:flex-start;justify-content:center;gap:12px}.world-visual.mixed{width:min(100%,620px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.world-photo{position:relative;min-width:0;margin:0;overflow:hidden;border:1px solid color-mix(in srgb,var(--line) 75%,var(--ink));border-radius:19px;background:#152432;box-shadow:0 10px 24px rgba(20,36,52,.18)}.world-picture{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover}.world-photo-badge{position:absolute;left:9px;top:9px;padding:5px 9px;border:1px solid rgba(255,255,255,.35);border-radius:999px;background:rgba(10,27,42,.78);color:#fff;font-size:10px;font-weight:950;letter-spacing:.45px;text-transform:uppercase;box-shadow:0 2px 8px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.world-photo-credit{position:absolute;right:7px;bottom:7px;max-width:calc(100% - 14px);overflow:hidden;padding:4px 7px;border-radius:8px;background:rgba(10,27,42,.8);color:#fff!important;font-size:9px;font-weight:750;line-height:1.2;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;backdrop-filter:blur(6px)}.world-photo-credit:hover,.world-photo-credit:focus-visible{text-decoration:underline;outline:2px solid #fff;outline-offset:1px} .world-identity{display:flex;flex-direction:column;align-items:center;gap:2px;text-align:center}.world-identity h3{margin:0;font-size:18px;line-height:1.2}.world-identity em{color:var(--muted);font-size:12px;font-family:Georgia,serif;font-weight:650} .world-answers{width:min(100%,560px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.world-answers .tbtn{min-width:0;min-height:48px;padding:8px 11px;border:1px solid var(--line);background:var(--surface);box-shadow:0 3px 8px rgba(20,36,52,.06)}.world-answers .tbtn:hover{border-color:var(--accent);background:var(--accent-soft)}.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{width:min(100%,560px);margin:0;padding:11px 14px;border:1px solid color-mix(in srgb,var(--green) 28%,var(--line));border-radius:15px;background:#eef7e9;color:#2f6f3e;text-align:center}.world-fact .world-identity em{color:#527c5b}.world-fact p{margin:6px 0 0;font-weight:800;line-height:1.4} +.world-answers .tbtn.wrong{background:color-mix(in srgb,var(--red) 14%,var(--surface));color:var(--red);border-color:var(--red)}.world-clue{width:min(100%,560px);padding:18px 20px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));border-radius:18px;background:linear-gradient(135deg,var(--accent-soft),var(--surface));color:var(--accent-ink);font-size:clamp(16px,3.2cqi,21px);font-weight:850;line-height:1.5;text-align:center}.world-clue-latin{font-family:Georgia,serif;font-size:clamp(23px,4.6cqi,34px);font-style:italic;font-weight:700} +.world-photo-answers{width:min(100%,640px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.world-photo-choice{position:relative;min-width:0;margin:0;overflow:hidden;border:3px solid transparent;border-radius:18px;background:#152432;box-shadow:0 8px 18px rgba(20,36,52,.16);transition:border-color .15s,transform .15s,box-shadow .15s}.world-image-button{display:block;width:100%;margin:0;padding:0;border:0;background:#152432;cursor:pointer}.world-image-button .world-picture{transition:transform .18s,filter .18s}.world-photo-choice:hover{transform:translateY(-2px);box-shadow:0 12px 22px rgba(20,36,52,.2)}.world-image-button:hover .world-picture{transform:scale(1.025)}.world-image-button:focus-visible{outline:4px solid var(--accent);outline-offset:-4px}.world-choice-number{position:absolute;left:8px;top:8px;display:grid;width:28px;height:28px;place-items:center;border:2px solid rgba(255,255,255,.7);border-radius:50%;background:rgba(10,27,42,.86);color:#fff;font-size:13px;font-weight:950;pointer-events:none}.world-photo-choice.correct{border-color:var(--green);box-shadow:0 0 0 3px color-mix(in srgb,var(--green) 25%,transparent)}.world-photo-choice.wrong{border-color:var(--red)}.world-photo-choice.wrong .world-picture{filter:saturate(.65) brightness(.82)} .world-gallery{width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.world-specimen{min-width:0;display:flex;flex-direction:column;align-items:center;gap:9px;padding:11px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(155deg,var(--surface),var(--surface-2));box-shadow:0 8px 22px rgba(20,36,52,.08)}.world-specimen .world-visual{width:100%}.world-specimen p{margin:0;padding:0 5px 3px;color:var(--muted);font-size:12px;font-weight:720;line-height:1.4;text-align:center} .topo-card{width:min(100%,740px)}.topo-map-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:7px;overflow:hidden;padding:10px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(155deg,#edf8fd,#dff2fb)}.topo-map{display:block;width:min(100%,510px);height:auto;max-height:590px;touch-action:manipulation;filter:drop-shadow(0 9px 13px rgba(29,60,76,.13))}.topo-water{fill:#dff3fb}.topo-province{stroke:#fff;stroke-width:4.5;stroke-linejoin:round;vector-effect:non-scaling-stroke;transition:filter .15s,opacity .15s,fill .15s}.topo-province[role=button]{cursor:pointer}.topo-province[role=button]:hover,.topo-province[role=button]:focus{filter:brightness(.88) saturate(1.12);outline:none}.topo-province[role=button]:focus-visible{stroke:var(--navy);stroke-width:8}.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:5px;stroke-linejoin:round;font-size:18px;font-weight:950;text-anchor:middle;pointer-events:none}.topo-city-label{text-anchor:start;font-size:16px}.topo-city-label.left{text-anchor:end}.topo-marker{cursor:pointer}.topo-hit{fill:transparent}.topo-dot{fill:var(--navy);stroke:#fff;stroke-width:4;transition:fill .15s,stroke-width .15s}.topo-marker:hover .topo-dot,.topo-marker:focus .topo-dot{fill:var(--orange);outline:none}.topo-marker:focus-visible .topo-dot{stroke:var(--navy);stroke-width:8}.topo-marker.picked .topo-dot{fill:var(--red)}.topo-marker.target .topo-dot{fill:var(--green);stroke-width:7}.topo-source{align-self:flex-end;color:#3b657b;font-size:9px;font-weight:750;text-decoration:none}.topo-source:hover,.topo-source:focus-visible{text-decoration:underline} -body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .world-identity em{color:#a9e5b7}body.dark .topo-map-wrap{background:linear-gradient(155deg,#193446,#122c3e)}body.dark .topo-water{fill:#173145}body.dark .topo-label,body.dark .topo-city-label{fill:#f7fbff;stroke:#18324e}body.dark .topo-source{color:#a9cadb} +.topo-province.inactive{opacity:.32}.topo-region[role=button],.topo-river[role=button],.world-continent[role=button]{cursor:pointer}.topo-region.picked .topo-province{fill:var(--red)!important}.topo-region.target .topo-province{fill:var(--green)!important}.topo-region:hover .topo-province,.topo-region:focus .topo-province{filter:brightness(.88) saturate(1.12)}.topo-region:focus-visible .topo-province{stroke:var(--navy);stroke-width:8}.topo-region-label{font-size:20px} +.topo-river.inactive{opacity:.22}.topo-river-hit,.topo-river-line{fill:none;stroke-linecap:round;stroke-linejoin:round}.topo-river-hit{stroke:transparent;stroke-width:38;pointer-events:stroke}.topo-river-line{stroke:var(--river-color);stroke-width:10;pointer-events:none;transition:stroke .15s,stroke-width .15s}.topo-river:hover .topo-river-line,.topo-river:focus .topo-river-line{stroke-width:15}.topo-river:focus-visible .topo-river-line{stroke:var(--navy);stroke-width:18}.topo-river.picked .topo-river-line{stroke:var(--red)}.topo-river.target .topo-river-line{stroke:var(--green);stroke-width:16}.topo-river-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:5px;stroke-linejoin:round;font-size:20px;font-style:italic;font-weight:950;text-anchor:middle;pointer-events:none} +.world-map-wrap{padding:12px}.world-map{width:100%;max-width:680px;max-height:none}.world-continent{stroke:#fff;stroke-width:1.4;stroke-linejoin:round;vector-effect:non-scaling-stroke;transition:filter .15s,opacity .15s,fill .15s}.world-continent.inactive{opacity:.28}.world-continent:hover,.world-continent:focus{filter:brightness(.88) saturate(1.12);outline:none}.world-continent:focus-visible{stroke:var(--navy);stroke-width:4}.world-continent.picked{fill:var(--red)!important}.world-continent.target{fill:var(--green)!important}.world-continent-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:3px;stroke-linejoin:round;font-size:14px;font-weight:950;text-anchor:middle;pointer-events:none} +.topo-world-legend{width:100%;display:flex;flex-wrap:wrap;justify-content:center;gap:6px}.topo-world-legend span{display:flex;align-items:center;gap:5px;padding:5px 8px;border:1px solid color-mix(in srgb,var(--line) 75%,transparent);border-radius:999px;background:var(--surface);color:var(--ink);font-size:11px;font-weight:850}.topo-world-legend i{width:10px;height:10px;flex:none;border:1px solid rgba(24,50,78,.18);border-radius:50%} +body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .world-identity em{color:#a9e5b7}body.dark .topo-map-wrap{background:linear-gradient(155deg,#193446,#122c3e)}body.dark .topo-water{fill:#173145}body.dark .topo-label,body.dark .topo-city-label,body.dark .topo-river-label,body.dark .world-continent-label{fill:#f7fbff;stroke:#18324e}body.dark .topo-source{color:#a9cadb} .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} @@ -1746,8 +1752,15 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .wo .world-visual.mixed{gap:6px} .world-photo-badge{left:6px;top:6px;padding:4px 7px;font-size:8px} .world-photo-credit{right:5px;bottom:5px;max-width:calc(100% - 10px);font-size:8px} + .world-photo-answers{gap:6px} + .world-clue{padding:13px 12px} .topo-map-wrap{padding:6px} .topo-map{max-height:450px} + .world-map-wrap{padding:4px} + .world-map{max-height:none} + .world-continent-label{font-size:12px} + .topo-world-legend{gap:4px} + .topo-world-legend span{padding:4px 6px;font-size:10px} .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} diff --git a/public/img/world/ATTRIBUTION.md b/public/img/world/ATTRIBUTION.md index 7fef633..40474c2 100644 --- a/public/img/world/ATTRIBUTION.md +++ b/public/img/world/ATTRIBUTION.md @@ -54,3 +54,15 @@ provinciecontouren worden onder dezelfde licentie verspreid. De plaatsing van stadspunten is daarnaast gecontroleerd met [Map provinces Netherlands-en.svg](https://commons.wikimedia.org/wiki/File:Map_provinces_Netherlands-en.svg) door Alphathon ([CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/)). + +`world-continents.svg` is gegenereerd uit de fysieke +[Natural Earth 1:110m Geography Regions Polygons](https://www.naturalearthdata.com/downloads/110m-physical-vectors/110m-physical-labels/) +en, voor de eilanden van Oceanië, +[Admin 0 Countries](https://www.naturalearthdata.com/downloads/110m-cultural-vectors/110m-admin-0-countries/). +Natural Earth stelt deze geografische brondata beschikbaar als publiek domein. +De lokale afgeleide bevat zeven afzonderlijk bedienbare werelddeelcontouren. + +De vier Nederlandse landsdelen volgen de +[CBS-indeling voor Gebieden in Nederland 2026](https://www.cbs.nl/nl-nl/cijfers/detail/86247NED). +De selectie en loop van de grote rivieren zijn gecontroleerd aan de hand van het +[Rijkswaterstaat-overzicht Rivieren](https://www.rijkswaterstaat.nl/water/waterbeheer/beheer-en-ontwikkeling-rijkswateren/rivieren). diff --git a/public/img/world/world-continents.svg b/public/img/world/world-continents.svg new file mode 100644 index 0000000..beb35c1 --- /dev/null +++ b/public/img/world/world-continents.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/js/core.js b/public/js/core.js index adebe24..deeb28d 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.70-beta"; +const VERSION = "0.3.71-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -119,15 +119,18 @@ 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:"Herken acht Nederlandse boomsoorten op echte foto's van bomen en bladeren.", - wg_birds:"Vogels herkennen", wg_birds_d:"Herken acht veelvoorkomende Nederlandse vogels op echte natuurfoto's.", - wg_topography:"Topografie Nederland", wg_topography_d:"Oefen provincies, provinciehoofdsteden en belangrijke steden op een nauwkeurige kaart.", - worldActivity:"Werkvorm", worldLearn:"Leren", worldQuiz:"Quiz", worldLevel:"Niveau", worldTreeView:"Wat zie je?", worldTreeWhole:"Hele boom", worldTreeLeaf:"Blad", worldTreeMixed:"Boom en blad", + wg_trees:"Bomen & bladeren", wg_trees_d:"Leer acht Nederlandse boomsoorten met vijf foto-, kenmerk- en koppeloefeningen.", + wg_birds:"Vogels herkennen", wg_birds_d:"Leer acht Nederlandse vogels met vier foto-, kenmerk- en naamoefeningen.", + wg_topography:"Topografie Nederland", wg_topography_d:"Oefen provincies, plaatsen, landsdelen, rivieren en werelddelen op nauwkeurige kaarten.", + worldActivity:"Werkvorm", worldLearn:"Leren", worldQuiz:"Quiz", worldExercise:"Oefening", worldLevel:"Niveau", worldTreeView:"Wat zie je?", worldTreeWhole:"Hele boom", worldTreeLeaf:"Blad", worldTreeMixed:"Boom en blad", + worldExerciseRecognize:"Herken de soort", worldExercisePair:"Koppel boom en blad", worldExerciseClue:"Raad met kenmerken", worldExercisePhoto:"Kies de juiste foto", worldExerciseLatin:"Wetenschappelijke naam", 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?", + worldQuestionPair:"Bij welke boom horen deze boom en dit blad?", worldQuestionClue:"Welke soort hoort bij deze kenmerken?", worldQuestionLatin:"Welke soort hoort bij deze wetenschappelijke naam?", worldFindTreePhoto:"Tik op de foto van de {name}.", worldFindBirdPhoto:"Tik op de foto van de {name}.", worldCorrect:"Goed herkend! 🌟", worldTryAgain:"Kijk nog eens goed naar de kenmerken.", worldNext:"Volgende opdracht", - worldPhotoTree:"Boom", worldPhotoLeaf:"Blad of naalden", worldPhotoBird:"Vogel", worldPhotoCredit:"Foto: {author} · {license}", - topoMode:"Oefenen met", topoProvinces:"Provincies", topoCapitals:"Provinciehoofdsteden", topoCities:"Belangrijke steden", topoMapLabel:"Kaart van Nederland", - topoExploreHint:"Bekijk de echte provinciegrenzen en vergelijk de ligging van de plaatsen.", topoFindProvince:"Tik op de provincie {name}.", topoFindCapital:"Waar ligt provinciehoofdstad {name}?", topoFindCity:"Waar ligt {name}?", topoMapSource:"Kaart: Gust Justice · CC BY-SA 4.0", + worldPhotoTree:"Boom", worldPhotoLeaf:"Blad of naalden", worldPhotoBird:"Vogel", worldPhotoCredit:"Foto: {author} · {license}", worldPhotoOption:"Afbeelding {number}", + topoMode:"Oefenen met", topoProvinces:"Provincies", topoCapitals:"Provinciehoofdsteden", topoCities:"Belangrijke steden", topoRegions:"Landsdelen", topoRivers:"Rivieren", topoContinents:"Werelddelen", topoMapLabel:"Kaart van Nederland", topoWorldMapLabel:"Kaart van de wereld", + topoExploreHint:"Bekijk de echte provinciegrenzen en vergelijk de ligging van de plaatsen.", topoExploreRegions:"Bekijk welke provincies samen Noord-, Oost-, West- en Zuid-Nederland vormen.", topoExploreRivers:"Volg de loop van de grote Nederlandse rivieren van grens tot delta.", topoExploreContinents:"Vergelijk de vorm en ligging van de zeven werelddelen.", + topoFindProvince:"Tik op de provincie {name}.", topoFindCapital:"Waar ligt provinciehoofdstad {name}?", topoFindCity:"Waar ligt {name}?", topoFindRegion:"Tik op {name}.", topoFindRiver:"Tik op de rivier {name}.", topoFindContinent:"Tik op het werelddeel {name}.", topoMapSource:"Kaart: Gust Justice · CC BY-SA 4.0", topoWorldMapSource:"Wereldkaart: Natural Earth · publiek domein", 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", @@ -387,15 +390,18 @@ 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:"Recognise eight Dutch tree species from real photographs of trees and leaves.", - wg_birds:"Bird identification", wg_birds_d:"Recognise eight common Dutch birds from real nature photographs.", - wg_topography:"Map of the Netherlands", wg_topography_d:"Practise provinces, provincial capitals and important cities on an accurate map.", - worldActivity:"Activity", worldLearn:"Learn", worldQuiz:"Quiz", worldLevel:"Level", worldTreeView:"What do you see?", worldTreeWhole:"Whole tree", worldTreeLeaf:"Leaf", worldTreeMixed:"Tree and leaf", + wg_trees:"Trees & leaves", wg_trees_d:"Learn eight Dutch tree species with five photo, feature and matching exercises.", + wg_birds:"Bird identification", wg_birds_d:"Learn eight Dutch birds with four photo, feature and naming exercises.", + wg_topography:"Map of the Netherlands", wg_topography_d:"Practise provinces, places, regions, rivers and continents on accurate maps.", + worldActivity:"Activity", worldLearn:"Learn", worldQuiz:"Quiz", worldExercise:"Exercise", worldLevel:"Level", worldTreeView:"What do you see?", worldTreeWhole:"Whole tree", worldTreeLeaf:"Leaf", worldTreeMixed:"Tree and leaf", + worldExerciseRecognize:"Recognise the species", worldExercisePair:"Match tree and leaf", worldExerciseClue:"Guess from features", worldExercisePhoto:"Choose the correct photo", worldExerciseLatin:"Scientific name", 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?", + worldQuestionPair:"Which species do this tree and leaf belong to?", worldQuestionClue:"Which species matches these features?", worldQuestionLatin:"Which species matches this scientific name?", worldFindTreePhoto:"Tap the photograph of the {name}.", worldFindBirdPhoto:"Tap the photograph of the {name}.", worldCorrect:"Well recognised! 🌟", worldTryAgain:"Take another close look at the features.", worldNext:"Next task", - worldPhotoTree:"Tree", worldPhotoLeaf:"Leaf or needles", worldPhotoBird:"Bird", worldPhotoCredit:"Photo: {author} · {license}", - topoMode:"Practise", topoProvinces:"Provinces", topoCapitals:"Provincial capitals", topoCities:"Important cities", topoMapLabel:"Map of the Netherlands", - topoExploreHint:"Study the real provincial boundaries and compare the locations of the places.", topoFindProvince:"Tap the province {name}.", topoFindCapital:"Where is the provincial capital {name}?", topoFindCity:"Where is {name}?", topoMapSource:"Map: Gust Justice · CC BY-SA 4.0", + worldPhotoTree:"Tree", worldPhotoLeaf:"Leaf or needles", worldPhotoBird:"Bird", worldPhotoCredit:"Photo: {author} · {license}", worldPhotoOption:"Image {number}", + topoMode:"Practise", topoProvinces:"Provinces", topoCapitals:"Provincial capitals", topoCities:"Important cities", topoRegions:"Regions", topoRivers:"Rivers", topoContinents:"Continents", topoMapLabel:"Map of the Netherlands", topoWorldMapLabel:"Map of the world", + topoExploreHint:"Study the real provincial boundaries and compare the locations of the places.", topoExploreRegions:"Study which provinces make up the northern, eastern, western and southern Netherlands.", topoExploreRivers:"Follow the major Dutch rivers from the border to the delta.", topoExploreContinents:"Compare the shapes and locations of the seven continents.", + topoFindProvince:"Tap the province {name}.", topoFindCapital:"Where is the provincial capital {name}?", topoFindCity:"Where is {name}?", topoFindRegion:"Tap {name}.", topoFindRiver:"Tap the river {name}.", topoFindContinent:"Tap the continent {name}.", topoMapSource:"Map: Gust Justice · CC BY-SA 4.0", topoWorldMapSource:"World map: Natural Earth · public domain", 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", diff --git a/public/js/widgets/world.js b/public/js/widgets/world.js index 82d1bf5..b25b3cb 100644 --- a/public/js/widgets/world.js +++ b/public/js/widgets/world.js @@ -106,6 +106,31 @@ const NL_CITIES=Object.freeze([ {id:"tilburg",nl:"Tilburg",en:"Tilburg",province:"noord_brabant",capital:false,tier:3,x:347,y:625}, ]); +const NL_REGIONS=Object.freeze([ + {id:"north",nl:"Noord-Nederland",en:"Northern Netherlands",tier:1,color:"#60a5fa",provinces:["friesland","groningen","drenthe"],x:565,y:172}, + {id:"east",nl:"Oost-Nederland",en:"Eastern Netherlands",tier:1,color:"#a78bfa",provinces:["overijssel","gelderland","flevoland"],x:548,y:406}, + {id:"west",nl:"West-Nederland",en:"Western Netherlands",tier:1,color:"#22d3ee",provinces:["noord_holland","zuid_holland","utrecht","zeeland"],x:252,y:454}, + {id:"south",nl:"Zuid-Nederland",en:"Southern Netherlands",tier:1,color:"#fbbf24",provinces:["noord_brabant","limburg"],x:417,y:648}, +]); + +const NL_RIVERS=Object.freeze([ + {id:"rijn",nl:"Rijn",en:"Rhine",tier:1,color:"#2563eb",labelX:399,labelY:477,path:"M 567 510 C 535 508 515 493 489 491 C 452 488 429 472 394 474 C 353 476 322 494 280 502 C 257 507 238 508 218 514"}, + {id:"maas",nl:"Maas",en:"Meuse",tier:1,color:"#0891b2",labelX:401,labelY:581,path:"M 463 850 C 445 817 469 773 490 730 C 511 688 536 639 519 596 C 504 565 472 556 439 570 C 398 588 362 574 320 580 C 280 586 247 578 211 566"}, + {id:"ijssel",nl:"IJssel",en:"IJssel",tier:2,color:"#0ea5e9",labelX:536,labelY:389,path:"M 497 489 C 516 459 525 426 526 390 C 527 356 520 329 502 306 C 484 282 463 270 447 250"}, + {id:"waal",nl:"Waal",en:"Waal",tier:2,color:"#1d4ed8",labelX:421,labelY:548,path:"M 557 519 C 527 530 503 539 470 548 C 435 558 406 562 373 553 C 340 544 310 548 280 558 C 253 566 230 560 210 550"}, + {id:"schelde",nl:"Schelde",en:"Scheldt",tier:3,color:"#0284c7",labelX:139,labelY:641,path:"M 41 660 C 82 654 119 649 151 642 C 176 637 191 624 207 609"}, +]); + +const WORLD_CONTINENTS=Object.freeze([ + {id:"europe",shape:"europe",nl:"Europa",en:"Europe",tier:1,color:"#fbbf24",x:382,y:96}, + {id:"africa",shape:"africa",nl:"Afrika",en:"Africa",tier:1,color:"#fb923c",x:383,y:190}, + {id:"asia",shape:"asia",nl:"Azië",en:"Asia",tier:1,color:"#f87171",x:505,y:112}, + {id:"north_america",shape:"north-america",nl:"Noord-Amerika",en:"North America",tier:2,color:"#60a5fa",x:154,y:112}, + {id:"south_america",shape:"south-america",nl:"Zuid-Amerika",en:"South America",tier:2,color:"#34d399",x:245,y:252}, + {id:"oceania",shape:"oceania",nl:"Oceanië",en:"Oceania",tier:2,color:"#c084fc",x:593,y:248}, + {id:"antarctica",shape:"antarctica",nl:"Antarctica",en:"Antarctica",tier:3,color:"#cbd5e1",x:360,y:337}, +]); + const worldName=item=>LANG==="nl"?item.nl:item.en; const worldHint=item=>LANG==="nl"?item.hintNl:item.hintEn; const worldShuffle=items=>{ @@ -157,6 +182,37 @@ function worldVisual(item,type,view){ return visual; } +function worldPhotoOption(item,type,view,index,onChoose,status){ + const kind=type==="birds"?"bird":view; + const media=WORLD_MEDIA[item.id]?.[kind]; + const figure=document.createElement("figure"); + figure.className="world-photo-choice"+(status?" "+status:""); + const button=document.createElement("button"); + button.type="button"; + button.className="world-image-button"; + button.setAttribute("aria-label",T("worldPhotoOption").replace("{number}",index+1)); + const image=document.createElement("img"); + image.className="world-picture"; + image.alt=""; + image.draggable=false; + image.loading="lazy"; + image.decoding="async"; + image.src=WORLD_MEDIA_ROOT+media.file; + const number=document.createElement("span"); + number.className="world-choice-number"; + number.textContent=String(index+1); + button.append(image,number); + button.onclick=onChoose; + const credit=document.createElement("a"); + credit.className="world-photo-credit"; + credit.href=media.source; + credit.target="_blank"; + credit.rel="noopener noreferrer"; + credit.textContent=T("worldPhotoCredit").replace("{author}",media.author).replace("{license}",media.license); + figure.append(button,credit); + return figure; +} + function worldIdentity(item){ const identity=document.createElement("div"); identity.className="world-identity"; @@ -169,19 +225,26 @@ function worldIdentity(item){ } function mountWorldQuiz(root,init,config){ + const exerciseOptions=config.type==="trees" + ?[["recognize","worldExerciseRecognize"],["pair","worldExercisePair"],["clue","worldExerciseClue"],["photo","worldExercisePhoto"],["latin","worldExerciseLatin"]] + :[["recognize","worldExerciseRecognize"],["clue","worldExerciseClue"],["photo","worldExercisePhoto"],["latin","worldExerciseLatin"]]; const S=Object.assign({ - activity:"quiz",level:1,view:config.type==="trees"?"mixed":"bird", - score:0,streak:0,target:"",taskView:"",options:[],solved:false,feedback:"", + activity:"quiz",exercise:"recognize",level:1,view:config.type==="trees"?"mixed":"bird", + score:0,streak:0,target:"",taskView:"",options:[],picked:"",solved:false,feedback:"", },init||{}); S.level=Math.max(1,Math.min(3,+S.level||1)); + if(!exerciseOptions.some(([id])=>id===S.exercise))S.exercise="recognize"; const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"]]); + const exercise=worldSelect(S.exercise,exerciseOptions); const level=worldSelect(String(S.level),[["1","lvl1"],["2","lvl2"],["3","lvl3"]]); const stage=document.createElement("div"); - const settings=[field(T("worldActivity"),activity),field(T("worldLevel"),level)]; - let view; + const exerciseSetting=field(T("worldExercise"),exercise); + const settings=[field(T("worldActivity"),activity),exerciseSetting,field(T("worldLevel"),level)]; + let view,viewSetting; if(config.type==="trees"){ view=worldSelect(S.view,[["tree","worldTreeWhole"],["leaf","worldTreeLeaf"],["mixed","worldTreeMixed"]]); - settings.push(field(T("worldTreeView"),view)); + viewSetting=field(T("worldTreeView"),view); + settings.push(viewSetting); } const pool=()=>worldLevelPool(config.items,S.level); const current=()=>config.items.find(item=>item.id===S.target)||pool()[0]; @@ -190,14 +253,18 @@ function mountWorldQuiz(root,init,config){ 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.taskView=config.type==="trees" + ?S.exercise==="pair"?"mixed":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.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{ @@ -221,8 +288,11 @@ function mountWorldQuiz(root,init,config){ }; function draw(){ S.activity=activity.value; + S.exercise=exercise.value; S.level=+level.value; if(view)S.view=view.value; + exerciseSetting.hidden=S.activity==="learn"; + if(viewSetting)viewSetting.hidden=S.activity==="quiz"&&!["recognize","photo"].includes(S.exercise); stage.className="world-app"; stage.innerHTML=""; const top=document.createElement("div"); @@ -243,18 +313,49 @@ function mountWorldQuiz(root,init,config){ 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); + if(S.exercise==="photo"){ + question.textContent=T(config.type==="birds"?"worldFindBirdPhoto":"worldFindTreePhoto").replace("{name}",worldName(item)); + card.append(question); + const answers=document.createElement("div"); + answers.className="world-photo-answers"; + S.options.forEach((id,index)=>{ + const option=config.items.find(entry=>entry.id===id); + if(!option)return; + const status=S.solved&&id===S.target?"correct":S.feedback==="bad"&&id===S.picked?"wrong":""; + const optionView=config.type==="trees"?(S.taskView==="mixed"?"tree":S.taskView):"bird"; + answers.append(worldPhotoOption(option,config.type,optionView,index,()=>answer(id),status)); + }); + card.append(answers); + }else{ + const prompt=document.createElement("div"); + if(S.exercise==="clue"){ + question.textContent=T("worldQuestionClue"); + prompt.className="world-clue"; + prompt.textContent=worldHint(item); + }else if(S.exercise==="latin"){ + question.textContent=T("worldQuestionLatin"); + prompt.className="world-clue world-clue-latin"; + prompt.textContent=item.latin; + }else if(S.exercise==="pair"){ + question.textContent=T("worldQuestionPair"); + prompt.append(worldVisual(item,config.type,"mixed")); + }else{ + question.textContent=T(config.type==="birds"?"worldQuestionBird":S.taskView==="leaf"?"worldQuestionLeaf":"worldQuestionTree"); + prompt.append(worldVisual(item,config.type,S.taskView)); + } + card.append(question,prompt); + 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"); + else if(S.feedback==="bad"&&id===S.picked)button.classList.add("wrong"); + 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"); @@ -276,6 +377,7 @@ function mountWorldQuiz(root,init,config){ stage.append(card); } activity.onchange=()=>{S.activity=activity.value;if(S.activity==="quiz")newTask();else draw()}; + exercise.onchange=()=>{S.exercise=exercise.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); @@ -289,6 +391,8 @@ function mountBirds(root,init){return mountWorldQuiz(root,init,{type:"birds",ite const SVG_NS="http://www.w3.org/2000/svg"; const TOPO_MAP_ASSET=WORLD_MEDIA_ROOT+"netherlands-provinces.svg"; const TOPO_MAP_SOURCE="https://commons.wikimedia.org/wiki/File:Netherlands_provinces_map_blank.svg"; +const TOPO_WORLD_ASSET=WORLD_MEDIA_ROOT+"world-continents.svg"; +const TOPO_WORLD_SOURCE="https://www.naturalearthdata.com/downloads/"; function svgNode(name,attributes={}){ const node=document.createElementNS(SVG_NS,name); Object.entries(attributes).forEach(([key,value])=>node.setAttribute(key,value)); @@ -315,25 +419,71 @@ function makeTopoMap(mode,pool,onPick,showLabels,picked,target){ role:onPick?"group":"img","aria-label":T("topoMapLabel"), }); svg.append(svgNode("rect",{x:0,y:0,width:743,height:881,rx:34,class:"topo-water"})); - NL_PROVINCES.forEach(province=>{ - const shape=svgNode("use",{ - href:TOPO_MAP_ASSET+"#"+province.shape, - fill:province.color, - class:"topo-province"+(province.id===target?" target":"")+(province.id===picked?" picked":""), - "data-id":province.id, + const active=new Set(pool.map(item=>item.id)); + if(mode==="regions"){ + NL_REGIONS.forEach(region=>{ + const group=svgNode("g",{ + class:"topo-region"+(region.id===target?" target":"")+(region.id===picked?" picked":""), + "data-id":region.id, + }); + region.provinces.forEach(provinceId=>{ + const province=NL_PROVINCES.find(item=>item.id===provinceId); + if(province)group.append(svgNode("use",{href:TOPO_MAP_ASSET+"#"+province.shape,fill:region.color,class:"topo-province"})); + }); + const title=svgNode("title"); + title.textContent=worldName(region); + group.append(title); + if(onPick&&active.has(region.id))svgAction(group,worldName(region),()=>onPick(region.id)); + svg.append(group); + if(showLabels&&active.has(region.id)){ + const label=svgNode("text",{x:region.x,y:region.y,class:"topo-label topo-region-label"}); + label.textContent=worldName(region); + svg.append(label); + } }); + }else{ + NL_PROVINCES.forEach(province=>{ + const selectable=mode==="provinces"&&active.has(province.id); + const shape=svgNode("use",{ + href:TOPO_MAP_ASSET+"#"+province.shape, + fill:mode==="rivers"?"#dce8df":province.color, + class:"topo-province"+(province.id===target?" target":"")+(province.id===picked?" picked":"")+(mode==="provinces"&&!selectable?" inactive":""), + "data-id":province.id, + }); + const title=svgNode("title"); + title.textContent=worldName(province); + shape.append(title); + if(onPick&&selectable)svgAction(shape,worldName(province),()=>onPick(province.id)); + svg.append(shape); + if(showLabels&&mode==="provinces"&&selectable){ + const label=svgNode("text",{x:province.x,y:province.y,class:"topo-label"}); + label.textContent=worldName(province); + svg.append(label); + } + }); + } + if(mode==="rivers")NL_RIVERS.forEach(river=>{ + const selectable=active.has(river.id); + const group=svgNode("g",{ + class:"topo-river"+(river.id===target?" target":"")+(river.id===picked?" picked":"")+(!selectable?" inactive":""), + "data-id":river.id,style:"--river-color:"+river.color, + }); + group.append( + svgNode("path",{d:river.path,class:"topo-river-hit"}), + svgNode("path",{d:river.path,class:"topo-river-line"}), + ); const title=svgNode("title"); - title.textContent=worldName(province); - shape.append(title); - if(onPick&&mode==="provinces")svgAction(shape,worldName(province),()=>onPick(province.id)); - svg.append(shape); - 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); + title.textContent=worldName(river); + group.append(title); + if(onPick&&selectable)svgAction(group,worldName(river),()=>onPick(river.id)); + svg.append(group); + if(showLabels&&selectable){ + const label=svgNode("text",{x:river.labelX,y:river.labelY,class:"topo-river-label"}); + label.textContent=worldName(river); svg.append(label); } }); - if(mode!=="provinces")pool.forEach(city=>{ + if(mode==="capitals"||mode==="cities")pool.forEach(city=>{ const group=svgNode("g",{ class:"topo-marker"+(city.id===target?" target":"")+(city.id===picked?" picked":""), }); @@ -362,21 +512,82 @@ function makeTopoMap(mode,pool,onPick,showLabels,picked,target){ return wrap; } +function makeWorldMap(pool,onPick,showLabels,picked,target){ + const wrap=document.createElement("div"); + wrap.className="topo-map-wrap world-map-wrap"; + const svg=svgNode("svg",{ + class:"topo-map world-map",viewBox:"0 0 720 360", + role:onPick?"group":"img","aria-label":T("topoWorldMapLabel"), + }); + svg.append(svgNode("rect",{x:0,y:0,width:720,height:360,rx:24,class:"topo-water"})); + const active=new Set(pool.map(item=>item.id)); + WORLD_CONTINENTS.forEach(continent=>{ + const selectable=active.has(continent.id); + const shape=svgNode("use",{ + href:TOPO_WORLD_ASSET+"#"+continent.shape, + fill:continent.color, + class:"world-continent"+(continent.id===target?" target":"")+(continent.id===picked?" picked":"")+(!selectable?" inactive":""), + "data-id":continent.id, + }); + const title=svgNode("title"); + title.textContent=worldName(continent); + shape.append(title); + if(onPick&&selectable)svgAction(shape,worldName(continent),()=>onPick(continent.id)); + svg.append(shape); + if(showLabels&&selectable){ + const label=svgNode("text",{x:continent.x,y:continent.y,class:"world-continent-label"}); + label.textContent=worldName(continent); + svg.append(label); + } + }); + const source=document.createElement("a"); + source.className="topo-source"; + source.href=TOPO_WORLD_SOURCE; + source.target="_blank"; + source.rel="noopener noreferrer"; + source.textContent=T("topoWorldMapSource"); + wrap.append(svg); + if(showLabels){ + const legend=document.createElement("div"); + legend.className="topo-world-legend"; + pool.forEach(continent=>{ + const entry=document.createElement("span"); + const swatch=document.createElement("i"); + swatch.style.background=continent.color; + entry.append(swatch,worldName(continent)); + legend.append(entry); + }); + wrap.append(legend); + } + wrap.append(source); + return wrap; +} + function mountTopography(root,init){ + const modeOptions=[ + ["provinces","topoProvinces"],["capitals","topoCapitals"],["cities","topoCities"], + ["regions","topoRegions"],["rivers","topoRivers"],["continents","topoContinents"], + ]; 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)); + if(!modeOptions.some(([id])=>id===S.mode))S.mode="provinces"; const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"]]); - const mode=worldSelect(S.mode,[["provinces","topoProvinces"],["capitals","topoCapitals"],["cities","topoCities"]]); + const mode=worldSelect(S.mode,modeOptions); const level=worldSelect(String(S.level),[["1","lvl1"],["2","lvl2"],["3","lvl3"]]); const 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); + const levelSetting=field(T("worldLevel"),level); + const pool=()=>{ + const items=S.mode==="provinces"?NL_PROVINCES + :S.mode==="capitals"?NL_CITIES.filter(item=>item.capital) + :S.mode==="cities"?NL_CITIES.filter(item=>!item.capital) + :S.mode==="regions"?NL_REGIONS + :S.mode==="rivers"?NL_RIVERS + :WORLD_CONTINENTS; + return items.filter(item=>item.tier<=S.level); + }; const current=()=>pool().find(item=>item.id===S.target)||pool()[0]; const newTask=()=>{ const items=pool(),previous=S.target; @@ -397,6 +608,7 @@ function mountTopography(root,init){ }; function draw(){ S.activity=activity.value;S.mode=mode.value;S.level=+level.value; + levelSetting.hidden=S.mode==="regions"; stage.className="world-app topo-app"; stage.innerHTML=""; const top=document.createElement("div"); @@ -409,15 +621,24 @@ function mountTopography(root,init){ stage.append(top); const card=document.createElement("section"); card.className="world-task-card topo-card"; + const renderMap=(interactive,labels,pickedId,targetId)=>S.mode==="continents" + ?makeWorldMap(pool(),interactive,labels,pickedId,targetId) + :makeTopoMap(S.mode,pool(),interactive,labels,pickedId,targetId); 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,"","")); + intro.textContent=T(S.mode==="regions"?"topoExploreRegions":S.mode==="rivers"?"topoExploreRivers":S.mode==="continents"?"topoExploreContinents":"topoExploreHint"); + card.append(intro,renderMap(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 questionKey=S.mode==="provinces"?"topoFindProvince" + :S.mode==="capitals"?"topoFindCapital" + :S.mode==="cities"?"topoFindCity" + :S.mode==="regions"?"topoFindRegion" + :S.mode==="rivers"?"topoFindRiver" + :"topoFindContinent"; + question.textContent=T(questionKey).replace("{name}",worldName(item)); + card.append(question,renderMap(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"); @@ -435,7 +656,7 @@ function mountTopography(root,init){ learningShell(root,[ field(T("worldActivity"),activity), field(T("topoMode"),mode), - field(T("worldLevel"),level), + levelSetting, ],stage); if(S.activity==="quiz")newTask();else draw(); return{getState:()=>({...S})}; diff --git a/test/widgets.test.js b/test/widgets.test.js index 4371971..24a8195 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -66,28 +66,34 @@ test('wereldoriëntatiewidgets laden, registreren en vertalen', async()=>{ assert.equal((core.match(new RegExp('wg_'+id+'_d:','g'))||[]).length,2,id); } assert.equal((core.match(/catWorld:/g)||[]).length,2); - for(const key of ['worldActivity','worldLearn','worldQuiz','worldTreeView','worldQuestionLeaf','worldQuestionBird','worldPhotoTree','worldPhotoLeaf','worldPhotoBird','worldPhotoCredit','topoProvinces','topoCapitals','topoCities','topoFindProvince','topoFindCapital','topoFindCity','topoMapSource'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key); + for(const key of ['worldActivity','worldLearn','worldQuiz','worldExercise','worldExerciseRecognize','worldExercisePair','worldExerciseClue','worldExercisePhoto','worldExerciseLatin','worldTreeView','worldQuestionLeaf','worldQuestionBird','worldQuestionPair','worldQuestionClue','worldQuestionLatin','worldFindTreePhoto','worldFindBirdPhoto','worldPhotoTree','worldPhotoLeaf','worldPhotoBird','worldPhotoCredit','worldPhotoOption','topoProvinces','topoCapitals','topoCities','topoRegions','topoRivers','topoContinents','topoFindProvince','topoFindCapital','topoFindCity','topoFindRegion','topoFindRiver','topoFindContinent','topoMapSource','topoWorldMapSource'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key); + for(const exercise of ['recognize','pair','clue','photo','latin'])assert.ok(source.includes('"'+exercise+'"'),exercise); + for(const mode of ['regions','rivers','continents'])assert.ok(source.includes('["'+mode+'","topo'),mode); assert.match(source,/getState/); assert.ok(source.includes('name.textContent=worldName(item)')); assert.ok(source.includes('hint.textContent=worldHint(item)')); assert.doesNotMatch(source,/innerHTML\s*=\s*(?:worldName|worldHint)\s*\(/); }); -test('wereldoriëntatiedata bevat echte soortmedia en volledige Nederlandse topografie', async()=>{ - const [source,map,attribution]=await Promise.all([ +test('wereldoriëntatiedata bevat echte soortmedia en uitgebreide topografie', async()=>{ + const [source,map,worldMap,attribution]=await Promise.all([ readFile('public/js/widgets/world.js','utf8'), readFile('public/img/world/netherlands-provinces.svg','utf8'), + readFile('public/img/world/world-continents.svg','utf8'), readFile('public/img/world/ATTRIBUTION.md','utf8'), ]); const context={}; - runInNewContext(source+';globalThis.__world={trees:WORLD_TREES,birds:WORLD_BIRDS,media:WORLD_MEDIA,provinces:NL_PROVINCES,cities:NL_CITIES}',context); + runInNewContext(source+';globalThis.__world={trees:WORLD_TREES,birds:WORLD_BIRDS,media:WORLD_MEDIA,provinces:NL_PROVINCES,cities:NL_CITIES,regions:NL_REGIONS,rivers:NL_RIVERS,continents:WORLD_CONTINENTS}',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); + assert.equal(data.regions.length,4); + assert.equal(data.rivers.length,5); + assert.equal(data.continents.length,7); + for(const collection of [data.trees,data.birds,data.provinces,data.cities,data.regions,data.rivers,data.continents])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.latin&&item.hintNl&&item.hintEn&&item.tier>=1&&item.tier<=3,item.id); const assetPaths=[]; for(const tree of data.trees){ @@ -113,10 +119,25 @@ test('wereldoriëntatiedata bevat echte soortmedia en volledige Nederlandse topo }); for(const province of data.provinces){ assert.ok(province.shape&&Number.isFinite(province.x)&&Number.isFinite(province.y)&&cities.has(province.capital),province.id); - assert.match(map,new RegExp(']+d="[^"]+"')); + assert.match(map,new RegExp(']+d="[^"]+"')); } assert.equal((map.match(/region.provinces); + assert.equal(regionProvinces.length,12); + assert.equal(new Set(regionProvinces).size,12); + assert.deepEqual([...regionProvinces].sort(),data.provinces.map(province=>province.id).sort()); + for(const river of data.rivers)assert.ok(/^M [\d ]+/.test(river.path)&&river.color&&Number.isFinite(river.labelX)&&Number.isFinite(river.labelY),river.id); + for(const continent of data.continents){ + assert.ok(continent.shape&&continent.color&&Number.isFinite(continent.x)&&Number.isFinite(continent.y),continent.id); + assert.match(worldMap,new RegExp('')); + } + assert.equal((worldMap.match(/{ @@ -338,7 +359,10 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- '.money-task-card,.clock-task-card,.world-task-card', '.world-gallery{grid-template-columns:1fr}', '.world-photo-credit', + '.world-photo-answers{gap:6px}', '.topo-map-wrap{padding:6px}', + '.world-map-wrap{padding:4px}', + '.topo-world-legend{gap:4px}', ]){ assert.ok(compact.includes(selector),selector+' mist in de compacte containerregels'); }