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');
}