diff --git a/VERSION b/VERSION
index 852a34b..d85ea03 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.69-beta
+0.3.70-beta
diff --git a/public/css/teach.css b/public/css/teach.css
index b4c21a7..aeb4ea8 100644
--- a/public/css/teach.css
+++ b/public/css/teach.css
@@ -1592,13 +1592,14 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2
/* Wereldoriëntatie: natuurherkenning en topografie */
.world-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)}
-.world-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.world-top>strong{font-size:15px}.world-top>span{padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900;white-space:nowrap}
-.world-task-card{width:min(100%,650px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)}.world-task-card h3{margin:0;text-align:center;font-size:clamp(19px,4cqi,26px)}.world-intro{width:100%;margin:0;padding:9px 12px;border-radius:12px;background:var(--accent-soft);color:var(--accent-ink);font-weight:800;text-align:center}
-.world-visual{width:min(100%,330px);display:flex;align-items:center;justify-content:center;gap:10px}.world-visual.mixed{width:min(100%,500px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.world-picture{display:block;width:100%;max-height:230px;object-fit:contain;border-radius:16px;border:1px solid var(--line);background:var(--surface-2)}
-.world-answers{width:min(100%,520px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.world-answers .tbtn{min-width:0;min-height:46px;padding:7px 10px}.world-answers .tbtn.correct{background:var(--green);color:#fff;border-color:transparent}.world-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.world-feedback.ok{color:var(--green)}.world-feedback.bad{color:var(--red)}.world-actions{display:flex;justify-content:center}.world-fact{max-width:520px;margin:0;padding:9px 12px;border-radius:12px;background:#eef7e9;color:#2f6f3e;font-weight:800;text-align:center}
-.world-gallery{width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.world-specimen{min-width:0;display:flex;flex-direction:column;align-items:center;gap:7px;padding:10px;border:1px solid var(--line);border-radius:16px;background:var(--surface)}.world-specimen .world-visual{width:100%}.world-specimen .world-picture{max-height:160px}.world-specimen h3{margin:0;font-size:17px;text-align:center}.world-specimen p{margin:0;color:var(--muted);font-size:12px;font-weight:700;line-height:1.35;text-align:center}
-.topo-card{width:min(100%,700px)}.topo-map-wrap{width:100%;display:flex;justify-content:center;overflow:auto}.topo-map{display:block;width:min(100%,430px);height:auto;max-height:500px;touch-action:manipulation}.topo-water{fill:#e4f4fb}.topo-province{stroke:var(--surface);stroke-width:3;vector-effect:non-scaling-stroke;transition:filter .15s,opacity .15s}.topo-province[role=button]{cursor:pointer}.topo-province[role=button]:hover,.topo-province[role=button]:focus{filter:brightness(.88);outline:none}.topo-province.picked{fill:var(--red)!important}.topo-province.target{fill:var(--green)!important}.topo-label,.topo-city-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:3px;stroke-linejoin:round;font-size:10px;font-weight:950;text-anchor:middle;pointer-events:none}.topo-city-label{text-anchor:start;font-size:9px}.topo-marker{cursor:pointer}.topo-hit{fill:transparent}.topo-dot{fill:var(--navy);stroke:#fff;stroke-width:2}.topo-marker:hover .topo-dot,.topo-marker:focus .topo-dot{fill:var(--orange);outline:none}.topo-marker.picked .topo-dot{fill:var(--red)}.topo-marker.target .topo-dot{fill:var(--green);r:9}
-body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .topo-water{fill:#173145}body.dark .topo-label,body.dark .topo-city-label{fill:#f7fbff;stroke:#18324e}
+.world-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;padding:9px 4px;background:color-mix(in srgb,var(--surface) 94%,transparent);border-bottom:1px solid var(--line);backdrop-filter:blur(10px)}.world-top>strong{font-size:15px}.world-top>span{padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900;white-space:nowrap}
+.world-task-card{width:min(100%,680px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:13px;padding:18px;border:1px solid color-mix(in srgb,var(--line) 80%,var(--accent));border-radius:24px;background:linear-gradient(155deg,var(--surface),color-mix(in srgb,var(--surface-2) 68%,var(--surface)));box-shadow:0 18px 40px rgba(24,42,66,.12)}.world-task-card>h3{max-width:560px;margin:0;text-align:center;font-size:clamp(19px,4cqi,27px);line-height:1.2}.world-intro{width:100%;margin:0;padding:10px 14px;border-radius:14px;background:linear-gradient(120deg,var(--accent-soft),color-mix(in srgb,var(--accent-soft) 55%,var(--surface)));color:var(--accent-ink);font-weight:850;line-height:1.4;text-align:center}
+.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-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}
.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}
@@ -1742,10 +1743,11 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .topo-water{fil
.money-task-card,.clock-task-card,.world-task-card{gap:9px;padding:10px}
.world-top{top:-10px;gap:5px}
.world-gallery{grid-template-columns:1fr}
- .world-picture{max-height:190px}
- .world-specimen .world-picture{max-height:150px}
+ .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}
+ .topo-map-wrap{padding:6px}
.topo-map{max-height:450px}
- .topo-label,.topo-city-label{font-size:8px}
.money-top,.clock-top{top:-10px;gap:5px}
.money-level-control>span,.clock-level-control>span{display:none}
.money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px}
diff --git a/public/img/world/ATTRIBUTION.md b/public/img/world/ATTRIBUTION.md
new file mode 100644
index 0000000..7fef633
--- /dev/null
+++ b/public/img/world/ATTRIBUTION.md
@@ -0,0 +1,56 @@
+# Wereldoriëntatie – beeldbronnen
+
+De lokale WebP-bestanden zijn uitsluitend verkleinde en, waar nodig, gekaderde
+afgeleiden van de hieronder genoemde foto's. De soortnamen zijn gecontroleerd
+aan de hand van de bestandsbeschrijvingen op Wikimedia Commons.
+
+## Bomen
+
+| Bestand | Bronwerk | Maker | Licentie |
+| --- | --- | --- | --- |
+| `trees/oak.webp` | [1. English Oak (Quercus robur)](https://commons.wikimedia.org/wiki/File:1._English_Oak_(Quercus_robur)_(3607652442).jpg) | AnemoneProjectors | [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/) |
+| `trees/birch.webp` | [Betula pendula winter](https://commons.wikimedia.org/wiki/File:Betula_pendula_winter.jpg) | Maplan | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `trees/horse-chestnut.webp` | [Paardenkastanje2](https://commons.wikimedia.org/wiki/File:Paardenkastanje2.jpg) | Toubib | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `trees/pine.webp` | [Pinus sylvestris Glenmuick](https://commons.wikimedia.org/wiki/File:Pinus_sylvestris_Glenmuick.jpg) | Hello, I am Bruce | [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/) |
+| `trees/beech.webp` | [Fagales – Fagus sylvatica](https://commons.wikimedia.org/wiki/File:Fagales_-_Fagus_sylvatica.jpg) | Emőke Dénes | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
+| `trees/maple.webp` | [Acer-pseudoplatanus](https://commons.wikimedia.org/wiki/File:Acer-pseudoplatanus.jpg) | MPF | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `trees/willow.webp` | [Salix alba an der Isar](https://commons.wikimedia.org/wiki/File:Salix_alba_an_der_Isar.jpg) | Burkhard Mücke | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
+| `trees/alder.webp` | [Alnus glutinosa 011](https://commons.wikimedia.org/wiki/File:Alnus_glutinosa_011.jpg) | Willow | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) |
+
+## Bladeren en naalden
+
+| Bestand | Bronwerk | Maker | Licentie |
+| --- | --- | --- | --- |
+| `leaves/oak.webp` | [Zomereik blad Quercus robur](https://commons.wikimedia.org/wiki/File:Zomereik_blad_Quercus_robur.jpg) | Rasbak | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `leaves/birch.webp` | [Atlas roslin pl Brzoza brodawkowata 1393 6470](https://commons.wikimedia.org/wiki/File:Atlas_roslin_pl_Brzoza_brodawkowata_1393_6470.jpg) | Joanna Boisse | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
+| `leaves/horse-chestnut.webp` | [Aesculus hippocastanum in botanical garden of UKW Bydgoszcz (2)](https://commons.wikimedia.org/wiki/File:Aesculus_hippocastanum_in_botanical_garden_of_UKW_Bydgoszcz_(2).jpg) | Tournasol7 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
+| `leaves/pine.webp` | [20151215Pinus sylvestris2](https://commons.wikimedia.org/wiki/File:20151215Pinus_sylvestris2.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
+| `leaves/beech.webp` | [20130530Hain Rotbuche](https://commons.wikimedia.org/wiki/File:20130530Hain_Rotbuche.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
+| `leaves/maple.webp` | [Acer pseudoplatanus 003](https://commons.wikimedia.org/wiki/File:Acer_pseudoplatanus_003.jpg) | Willow | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) |
+| `leaves/willow.webp` | [20160721Salix alba1](https://commons.wikimedia.org/wiki/File:20160721Salix_alba1.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
+| `leaves/alder.webp` | [20200531Alnus glutinosa2](https://commons.wikimedia.org/wiki/File:20200531Alnus_glutinosa2.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
+
+## Vogels
+
+| Bestand | Bronwerk | Maker | Licentie |
+| --- | --- | --- | --- |
+| `birds/robin.webp` | [Erithacus rubecula, Roodborstje](https://commons.wikimedia.org/wiki/File:Erithacus_rubecula,_Roodborstje.jpg) | Rasbak | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `birds/great-tit.webp` | [Parus major m](https://commons.wikimedia.org/wiki/File:Parus_major_m.jpg) | Sławek Staszczuk | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `birds/blackbird.webp` | [Solsort](https://commons.wikimedia.org/wiki/File:Solsort.jpg) | Malene Thyssen | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `birds/mallard.webp` | [Duisburg 10401IMG 0401](https://commons.wikimedia.org/wiki/File:Duisburg_10401IMG_0401.JPG) | Mbdortmund | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) |
+| `birds/blue-tit.webp` | [Cyanistes caeruleus 3 Luc Viatour](https://commons.wikimedia.org/wiki/File:Cyanistes_caeruleus_3_Luc_Viatour.jpg) | Luc Viatour | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+| `birds/house-sparrow.webp` | [Huismus, man](https://commons.wikimedia.org/wiki/File:Huismus,_man.jpg) | JrPol | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
+| `birds/magpie.webp` | [Сорока (Pica pica), Калининград](https://commons.wikimedia.org/wiki/File:%D0%A1%D0%BE%D1%80%D0%BE%D0%BA%D0%B0_(Pica_pica),_%D0%9A%D0%B0%D0%BB%D0%B8%D0%BD%D0%B8%D0%BD%D0%B3%D1%80%D0%B0%D0%B4.jpg) | Dion Art | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
+| `birds/woodpecker.webp` | [Grote bonte specht](https://commons.wikimedia.org/wiki/File:Grote_bonte_specht.JPG) | Usitea | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
+
+## Topografie
+
+`netherlands-provinces.svg` is een vereenvoudigde afgeleide zonder labels van
+[Netherlands provinces map blank.svg](https://commons.wikimedia.org/wiki/File:Netherlands_provinces_map_blank.svg)
+door Gust Justice, gelicentieerd onder
+[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). De afgeleide
+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/)).
diff --git a/public/img/world/birds/blackbird.webp b/public/img/world/birds/blackbird.webp
new file mode 100644
index 0000000..f8f59bb
Binary files /dev/null and b/public/img/world/birds/blackbird.webp differ
diff --git a/public/img/world/birds/blue-tit.webp b/public/img/world/birds/blue-tit.webp
new file mode 100644
index 0000000..f8d1aa5
Binary files /dev/null and b/public/img/world/birds/blue-tit.webp differ
diff --git a/public/img/world/birds/great-tit.webp b/public/img/world/birds/great-tit.webp
new file mode 100644
index 0000000..cc452dc
Binary files /dev/null and b/public/img/world/birds/great-tit.webp differ
diff --git a/public/img/world/birds/house-sparrow.webp b/public/img/world/birds/house-sparrow.webp
new file mode 100644
index 0000000..c0a0e58
Binary files /dev/null and b/public/img/world/birds/house-sparrow.webp differ
diff --git a/public/img/world/birds/magpie.webp b/public/img/world/birds/magpie.webp
new file mode 100644
index 0000000..db80bf3
Binary files /dev/null and b/public/img/world/birds/magpie.webp differ
diff --git a/public/img/world/birds/mallard.webp b/public/img/world/birds/mallard.webp
new file mode 100644
index 0000000..46422dc
Binary files /dev/null and b/public/img/world/birds/mallard.webp differ
diff --git a/public/img/world/birds/robin.webp b/public/img/world/birds/robin.webp
new file mode 100644
index 0000000..eb5c238
Binary files /dev/null and b/public/img/world/birds/robin.webp differ
diff --git a/public/img/world/birds/woodpecker.webp b/public/img/world/birds/woodpecker.webp
new file mode 100644
index 0000000..122c3ea
Binary files /dev/null and b/public/img/world/birds/woodpecker.webp differ
diff --git a/public/img/world/leaves/alder.webp b/public/img/world/leaves/alder.webp
new file mode 100644
index 0000000..0efcb15
Binary files /dev/null and b/public/img/world/leaves/alder.webp differ
diff --git a/public/img/world/leaves/beech.webp b/public/img/world/leaves/beech.webp
new file mode 100644
index 0000000..83e0bfe
Binary files /dev/null and b/public/img/world/leaves/beech.webp differ
diff --git a/public/img/world/leaves/birch.webp b/public/img/world/leaves/birch.webp
new file mode 100644
index 0000000..061adba
Binary files /dev/null and b/public/img/world/leaves/birch.webp differ
diff --git a/public/img/world/leaves/horse-chestnut.webp b/public/img/world/leaves/horse-chestnut.webp
new file mode 100644
index 0000000..a2c2f33
Binary files /dev/null and b/public/img/world/leaves/horse-chestnut.webp differ
diff --git a/public/img/world/leaves/maple.webp b/public/img/world/leaves/maple.webp
new file mode 100644
index 0000000..9c61c01
Binary files /dev/null and b/public/img/world/leaves/maple.webp differ
diff --git a/public/img/world/leaves/oak.webp b/public/img/world/leaves/oak.webp
new file mode 100644
index 0000000..dba612a
Binary files /dev/null and b/public/img/world/leaves/oak.webp differ
diff --git a/public/img/world/leaves/pine.webp b/public/img/world/leaves/pine.webp
new file mode 100644
index 0000000..0015c03
Binary files /dev/null and b/public/img/world/leaves/pine.webp differ
diff --git a/public/img/world/leaves/willow.webp b/public/img/world/leaves/willow.webp
new file mode 100644
index 0000000..37049c0
Binary files /dev/null and b/public/img/world/leaves/willow.webp differ
diff --git a/public/img/world/netherlands-provinces.svg b/public/img/world/netherlands-provinces.svg
new file mode 100644
index 0000000..bc86f4d
--- /dev/null
+++ b/public/img/world/netherlands-provinces.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/public/img/world/trees/alder.webp b/public/img/world/trees/alder.webp
new file mode 100644
index 0000000..151a543
Binary files /dev/null and b/public/img/world/trees/alder.webp differ
diff --git a/public/img/world/trees/beech.webp b/public/img/world/trees/beech.webp
new file mode 100644
index 0000000..5ed1fa4
Binary files /dev/null and b/public/img/world/trees/beech.webp differ
diff --git a/public/img/world/trees/birch.webp b/public/img/world/trees/birch.webp
new file mode 100644
index 0000000..4a8e3a3
Binary files /dev/null and b/public/img/world/trees/birch.webp differ
diff --git a/public/img/world/trees/horse-chestnut.webp b/public/img/world/trees/horse-chestnut.webp
new file mode 100644
index 0000000..6e9b0f5
Binary files /dev/null and b/public/img/world/trees/horse-chestnut.webp differ
diff --git a/public/img/world/trees/maple.webp b/public/img/world/trees/maple.webp
new file mode 100644
index 0000000..6f43ca1
Binary files /dev/null and b/public/img/world/trees/maple.webp differ
diff --git a/public/img/world/trees/oak.webp b/public/img/world/trees/oak.webp
new file mode 100644
index 0000000..d5c7947
Binary files /dev/null and b/public/img/world/trees/oak.webp differ
diff --git a/public/img/world/trees/pine.webp b/public/img/world/trees/pine.webp
new file mode 100644
index 0000000..5f24196
Binary files /dev/null and b/public/img/world/trees/pine.webp differ
diff --git a/public/img/world/trees/willow.webp b/public/img/world/trees/willow.webp
new file mode 100644
index 0000000..749b3fa
Binary files /dev/null and b/public/img/world/trees/willow.webp differ
diff --git a/public/js/core.js b/public/js/core.js
index 499eeff..adebe24 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.69-beta";
+const VERSION = "0.3.70-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@@ -119,14 +119,15 @@ const I18N = {
wg_clock:"Oefenklok", wg_clock_d:"Leer kloktijden op vier niveaus met meerdere leerwijzen en spellen.",
wg_placevalue:"Plaatswaardetabel", wg_placevalue_d:"Ontdek de waarde van cijfers tot honderdduizend.",
wg_money:"Geldrekenen", wg_money_d:"Oefen bedragen maken, geld tellen en wisselgeld op drie niveaus.",
- wg_trees:"Bomen & bladeren", wg_trees_d:"Leer acht Nederlandse bomen herkennen aan hun vorm en bladeren.",
- wg_birds:"Vogels herkennen", wg_birds_d:"Leer acht veelvoorkomende Nederlandse vogels herkennen aan kleur en vorm.",
- wg_topography:"Topografie Nederland", wg_topography_d:"Oefen provincies, provinciehoofdsteden en belangrijke steden op de kaart.",
+ 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",
worldScore:"Score: {score} · reeks: {streak}", worldExploreHint:"Bekijk de kenmerken en vergelijk de soorten.", worldQuestionTree:"Welke boom zie je?", worldQuestionLeaf:"Bij welke boom hoort dit blad?", worldQuestionBird:"Welke vogel zie je?",
worldCorrect:"Goed herkend! 🌟", worldTryAgain:"Kijk nog eens goed naar de kenmerken.", worldNext:"Volgende opdracht",
+ 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 waar de namen op de kaart liggen.", topoFindProvince:"Tik op de provincie {name}.", topoFindCapital:"Waar ligt provinciehoofdstad {name}?", topoFindCity:"Waar ligt {name}?",
+ 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",
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",
@@ -386,14 +387,15 @@ const I18N = {
wg_clock:"Practice clock", wg_clock_d:"Learn time at four levels with multiple learning styles and games.",
wg_placevalue:"Place value", wg_placevalue_d:"Explore digit values up to hundred thousands.",
wg_money:"Money", wg_money_d:"Practise making amounts, counting money and change at three levels.",
- wg_trees:"Trees & leaves", wg_trees_d:"Learn to recognise eight common trees by their shape and leaves.",
- wg_birds:"Bird identification", wg_birds_d:"Learn to recognise eight common Dutch birds by colour and shape.",
- wg_topography:"Map of the Netherlands", wg_topography_d:"Practise provinces, provincial capitals and important cities on the map.",
+ 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",
worldScore:"Score: {score} · streak: {streak}", worldExploreHint:"Study the features and compare the species.", worldQuestionTree:"Which tree do you see?", worldQuestionLeaf:"Which tree does this leaf belong to?", worldQuestionBird:"Which bird do you see?",
worldCorrect:"Well recognised! 🌟", worldTryAgain:"Take another close look at the features.", worldNext:"Next task",
+ 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 where the names belong on the map.", topoFindProvince:"Tap the province {name}.", topoFindCapital:"Where is the provincial capital {name}?", topoFindCity:"Where is {name}?",
+ 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",
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 77a43c0..82d1bf5 100644
--- a/public/js/widgets/world.js
+++ b/public/js/widgets/world.js
@@ -1,143 +1,442 @@
-/* Wereldoriëntatie: natuurherkenning en Nederlandse topografie. */
+/* Wereldoriëntatie: echte soortfoto's en geografisch correcte topografie. */
+"use strict";
+
+const WORLD_MEDIA_ROOT="img/world/";
+const worldMedia=(file,author,license,source)=>Object.freeze({file,author,license,source});
+const WORLD_MEDIA=Object.freeze({
+ oak:{
+ tree:worldMedia("trees/oak.webp","AnemoneProjectors","CC BY-SA 2.0","https://commons.wikimedia.org/wiki/File:1._English_Oak_(Quercus_robur)_(3607652442).jpg"),
+ leaf:worldMedia("leaves/oak.webp","Rasbak","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Zomereik_blad_Quercus_robur.jpg"),
+ },
+ birch:{
+ tree:worldMedia("trees/birch.webp","Maplan","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Betula_pendula_winter.jpg"),
+ leaf:worldMedia("leaves/birch.webp","Joanna Boisse","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Atlas_roslin_pl_Brzoza_brodawkowata_1393_6470.jpg"),
+ },
+ horse_chestnut:{
+ tree:worldMedia("trees/horse-chestnut.webp","Toubib","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Paardenkastanje2.jpg"),
+ leaf:worldMedia("leaves/horse-chestnut.webp","Tournasol7","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Aesculus_hippocastanum_in_botanical_garden_of_UKW_Bydgoszcz_(2).jpg"),
+ },
+ pine:{
+ tree:worldMedia("trees/pine.webp","Hello, I am Bruce","CC BY-SA 2.0","https://commons.wikimedia.org/wiki/File:Pinus_sylvestris_Glenmuick.jpg"),
+ leaf:worldMedia("leaves/pine.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20151215Pinus_sylvestris2.jpg"),
+ },
+ beech:{
+ tree:worldMedia("trees/beech.webp","Emőke Dénes","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Fagales_-_Fagus_sylvatica.jpg"),
+ leaf:worldMedia("leaves/beech.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20130530Hain_Rotbuche.jpg"),
+ },
+ maple:{
+ tree:worldMedia("trees/maple.webp","MPF","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Acer-pseudoplatanus.jpg"),
+ leaf:worldMedia("leaves/maple.webp","Willow","CC BY 2.5","https://commons.wikimedia.org/wiki/File:Acer_pseudoplatanus_003.jpg"),
+ },
+ willow:{
+ tree:worldMedia("trees/willow.webp","Burkhard Mücke","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Salix_alba_an_der_Isar.jpg"),
+ leaf:worldMedia("leaves/willow.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20160721Salix_alba1.jpg"),
+ },
+ alder:{
+ tree:worldMedia("trees/alder.webp","Willow","CC BY 2.5","https://commons.wikimedia.org/wiki/File:Alnus_glutinosa_011.jpg"),
+ leaf:worldMedia("leaves/alder.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20200531Alnus_glutinosa2.jpg"),
+ },
+ robin:{bird:worldMedia("birds/robin.webp","Rasbak","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Erithacus_rubecula,_Roodborstje.jpg")},
+ great_tit:{bird:worldMedia("birds/great-tit.webp","Sławek Staszczuk","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Parus_major_m.jpg")},
+ blackbird:{bird:worldMedia("birds/blackbird.webp","Malene Thyssen","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Solsort.jpg")},
+ mallard:{bird:worldMedia("birds/mallard.webp","Mbdortmund","CC BY 3.0","https://commons.wikimedia.org/wiki/File:Duisburg_10401IMG_0401.JPG")},
+ blue_tit:{bird:worldMedia("birds/blue-tit.webp","Luc Viatour","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Cyanistes_caeruleus_3_Luc_Viatour.jpg")},
+ house_sparrow:{bird:worldMedia("birds/house-sparrow.webp","JrPol","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Huismus,_man.jpg")},
+ magpie:{bird:worldMedia("birds/magpie.webp","Dion Art","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:%D0%A1%D0%BE%D1%80%D0%BE%D0%BA%D0%B0_(Pica_pica),_%D0%9A%D0%B0%D0%BB%D0%B8%D0%BD%D0%B8%D0%BD%D0%B3%D1%80%D0%B0%D0%B4.jpg")},
+ woodpecker:{bird:worldMedia("birds/woodpecker.webp","Usitea","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Grote_bonte_specht.JPG")},
+});
+
const WORLD_TREES=Object.freeze([
- {id:'oak',nl:'Eik',en:'Oak',tier:1,hintNl:'Herkenbaar aan de ronde bladlobben en eikels.',hintEn:'Recognisable by rounded leaf lobes and acorns.'},
- {id:'birch',nl:'Berk',en:'Birch',tier:1,hintNl:'Witte bast en driehoekige blaadjes met een gezaagde rand.',hintEn:'White bark and triangular leaves with a serrated edge.'},
- {id:'horse_chestnut',nl:'Paardenkastanje',en:'Horse chestnut',tier:1,hintNl:'Vijf tot zeven grote blaadjes vormen samen één handvormig blad.',hintEn:'Five to seven large leaflets form one hand-shaped leaf.'},
- {id:'pine',nl:'Grove den',en:'Scots pine',tier:1,hintNl:'Naalden staan per twee bij elkaar en de boom draagt kegels.',hintEn:'Needles grow in pairs and the tree carries cones.'},
- {id:'beech',nl:'Beuk',en:'Beech',tier:2,hintNl:'Gladgrijze stam en ovale blaadjes met een golvende rand.',hintEn:'Smooth grey trunk and oval leaves with a wavy edge.'},
- {id:'maple',nl:'Esdoorn',en:'Maple',tier:2,hintNl:'Puntig handvormig blad en gevleugelde zaden.',hintEn:'Pointed hand-shaped leaf and winged seeds.'},
- {id:'willow',nl:'Wilg',en:'Willow',tier:3,hintNl:'Lange smalle blaadjes aan vaak hangende takken.',hintEn:'Long narrow leaves on often drooping branches.'},
- {id:'alder',nl:'Els',en:'Alder',tier:3,hintNl:'Ronde blaadjes met een ingedeukte top en kleine houtige propjes.',hintEn:'Rounded leaves with a notched tip and small woody cones.'},
+ {id:"oak",nl:"Zomereik",en:"Pedunculate oak",latin:"Quercus robur",tier:1,hintNl:"Brede kroon, diep gelobde bladeren en eikels aan een steeltje.",hintEn:"Broad crown, deeply lobed leaves and acorns on a stalk."},
+ {id:"birch",nl:"Ruwe berk",en:"Silver birch",latin:"Betula pendula",tier:1,hintNl:"Witte bast met donkere strepen en driehoekige, gezaagde blaadjes.",hintEn:"White bark with dark markings and triangular, serrated leaves."},
+ {id:"horse_chestnut",nl:"Witte paardenkastanje",en:"Horse chestnut",latin:"Aesculus hippocastanum",tier:1,hintNl:"Vijf tot zeven grote blaadjes vormen samen één handvormig blad.",hintEn:"Five to seven large leaflets form one hand-shaped leaf."},
+ {id:"pine",nl:"Grove den",en:"Scots pine",latin:"Pinus sylvestris",tier:1,hintNl:"Oranjebruine schors bovenin; de blauwgroene naalden staan per twee.",hintEn:"Orange-brown upper bark; blue-green needles grow in pairs."},
+ {id:"beech",nl:"Beuk",en:"European beech",latin:"Fagus sylvatica",tier:2,hintNl:"Gladde grijze stam en glanzende ovale bladeren met een golvende rand.",hintEn:"Smooth grey trunk and glossy oval leaves with a wavy edge."},
+ {id:"maple",nl:"Gewone esdoorn",en:"Sycamore maple",latin:"Acer pseudoplatanus",tier:2,hintNl:"Groot handvormig blad met vijf puntige lobben en gevleugelde zaden.",hintEn:"Large hand-shaped leaf with five pointed lobes and winged seeds."},
+ {id:"willow",nl:"Schietwilg",en:"White willow",latin:"Salix alba",tier:3,hintNl:"Brede, losse kroon en lange smalle bladeren met een zilverige onderkant.",hintEn:"Broad open crown and long narrow leaves with a silvery underside."},
+ {id:"alder",nl:"Zwarte els",en:"Black alder",latin:"Alnus glutinosa",tier:3,hintNl:"Ronde bladeren met een ingedeukte top en kleine houtige elzenproppen.",hintEn:"Rounded leaves with a notched tip and small woody alder cones."},
]);
const WORLD_BIRDS=Object.freeze([
- {id:'robin',nl:'Roodborst',en:'European robin',tier:1,body:'#8a6a4b',head:'#786047',wing:'#6e543e',breast:'#ef6c32',bill:'#33261d',hintNl:'Oranjerode borst en gezicht, met een bruine rug.',hintEn:'Orange-red breast and face with a brown back.'},
- {id:'great_tit',nl:'Koolmees',en:'Great tit',tier:1,body:'#f2cf3f',head:'#18222b',wing:'#4b6670',breast:'#f2cf3f',bill:'#222',hintNl:'Zwarte kop, witte wangen en een zwarte streep over de gele buik.',hintEn:'Black head, white cheeks and a black stripe down the yellow belly.'},
- {id:'blackbird',nl:'Merel',en:'Common blackbird',tier:1,body:'#22262b',head:'#1b1e22',wing:'#30343a',breast:'#25292e',bill:'#f0a521',hintNl:'Het mannetje is zwart met een opvallend gele snavel.',hintEn:'The male is black with a striking yellow bill.'},
- {id:'mallard',nl:'Wilde eend',en:'Mallard',tier:1,body:'#a8a9a2',head:'#14604e',wing:'#766953',breast:'#7b4832',bill:'#e2b43f',hintNl:'Het mannetje heeft een groene kop, witte halsring en bruine borst.',hintEn:'The male has a green head, white neck ring and brown breast.'},
- {id:'blue_tit',nl:'Pimpelmees',en:'Blue tit',tier:2,body:'#f3d85a',head:'#3a78a7',wing:'#39759a',breast:'#f3d85a',bill:'#30343c',hintNl:'Blauw petje en vleugels, witte wangen en een gele buik.',hintEn:'Blue cap and wings, white cheeks and a yellow belly.'},
- {id:'house_sparrow',nl:'Huismus',en:'House sparrow',tier:2,body:'#9a7654',head:'#80654e',wing:'#6e5038',breast:'#b79a7c',bill:'#403329',hintNl:'Bruin gestreepte rug; het mannetje heeft een zwarte bef.',hintEn:'Brown streaked back; the male has a black bib.'},
- {id:'magpie',nl:'Ekster',en:'Eurasian magpie',tier:3,body:'#f4f5f5',head:'#182127',wing:'#172a33',breast:'#f4f5f5',bill:'#20262a',hintNl:'Zwart-wit verenkleed en een opvallend lange donkere staart.',hintEn:'Black-and-white plumage and a strikingly long dark tail.'},
- {id:'woodpecker',nl:'Grote bonte specht',en:'Great spotted woodpecker',tier:3,body:'#f4f1e8',head:'#20262b',wing:'#1f252a',breast:'#eee9dc',bill:'#373a3b',hintNl:'Zwart-wit met een rode vlek onder de staart.',hintEn:'Black and white with a red patch beneath the tail.'},
+ {id:"robin",nl:"Roodborst",en:"European robin",latin:"Erithacus rubecula",tier:1,hintNl:"Oranjerode borst en gezicht, met een olijfbruine rug.",hintEn:"Orange-red breast and face with an olive-brown back."},
+ {id:"great_tit",nl:"Koolmees",en:"Great tit",latin:"Parus major",tier:1,hintNl:"Zwarte kop, witte wangen en een zwarte streep over de gele buik.",hintEn:"Black head, white cheeks and a black stripe down the yellow belly."},
+ {id:"blackbird",nl:"Merel",en:"Common blackbird",latin:"Turdus merula",tier:1,hintNl:"Het mannetje is helemaal zwart met een gele snavel en gele oogring.",hintEn:"The male is all black with a yellow bill and yellow eye-ring."},
+ {id:"mallard",nl:"Wilde eend",en:"Mallard",latin:"Anas platyrhynchos",tier:1,hintNl:"Het mannetje heeft een groene kop, witte halsring en kastanjebruine borst.",hintEn:"The male has a green head, white neck ring and chestnut breast."},
+ {id:"blue_tit",nl:"Pimpelmees",en:"Blue tit",latin:"Cyanistes caeruleus",tier:2,hintNl:"Blauw petje en vleugels, witte wangen en een gele buik.",hintEn:"Blue cap and wings, white cheeks and a yellow belly."},
+ {id:"house_sparrow",nl:"Huismus",en:"House sparrow",latin:"Passer domesticus",tier:2,hintNl:"Bruin gestreepte rug; het mannetje heeft een grijze kruin en zwarte bef.",hintEn:"Brown streaked back; the male has a grey crown and black bib."},
+ {id:"magpie",nl:"Ekster",en:"Eurasian magpie",latin:"Pica pica",tier:3,hintNl:"Zwart-wit verenkleed met blauwgroene glans en een opvallend lange staart.",hintEn:"Black-and-white plumage with a blue-green sheen and a very long tail."},
+ {id:"woodpecker",nl:"Grote bonte specht",en:"Great spotted woodpecker",latin:"Dendrocopos major",tier:3,hintNl:"Zwart-wit met rode veren onder de staart; het mannetje heeft rood in de nek.",hintEn:"Black and white with red beneath the tail; the male has red on the nape."},
]);
const NL_PROVINCES=Object.freeze([
- {id:'friesland',nl:'Friesland',en:'Friesland',capital:'leeuwarden',tier:2,color:'#7bc8e8',d:'M90 85L95 55 145 38 205 45 235 75 220 115 175 135 125 115Z',x:158,y:82},
- {id:'groningen',nl:'Groningen',en:'Groningen',capital:'groningen_city',tier:2,color:'#ef8d71',d:'M205 45L265 30 330 50 315 90 270 105 235 75Z',x:275,y:67},
- {id:'drenthe',nl:'Drenthe',en:'Drenthe',capital:'assen',tier:3,color:'#e9c66b',d:'M220 115L270 105 310 125 300 180 245 190 220 160Z',x:266,y:145},
- {id:'overijssel',nl:'Overijssel',en:'Overijssel',capital:'zwolle',tier:2,color:'#78c49b',d:'M245 190L300 180 330 210 310 260 260 270 225 235Z',x:282,y:222},
- {id:'flevoland',nl:'Flevoland',en:'Flevoland',capital:'lelystad',tier:3,color:'#b5d969',d:'M150 170L215 160 245 190 225 235 175 245 145 210Z',x:195,y:202},
- {id:'noord_holland',nl:'Noord-Holland',en:'North Holland',capital:'haarlem',tier:1,color:'#f3a9c6',d:'M90 85L125 115 150 170 145 210 165 245 145 285 105 270 85 205 75 145Z',x:112,y:198},
- {id:'utrecht',nl:'Utrecht',en:'Utrecht',capital:'utrecht_city',tier:1,color:'#d0a6df',d:'M145 245L175 245 210 265 200 305 160 305 135 280Z',x:174,y:276},
- {id:'gelderland',nl:'Gelderland',en:'Gelderland',capital:'arnhem',tier:1,color:'#f0aa62',d:'M210 265L260 270 310 260 315 310 275 345 205 335 200 305Z',x:258,y:302},
- {id:'zuid_holland',nl:'Zuid-Holland',en:'South Holland',capital:'den_haag',tier:1,color:'#7fb2e6',d:'M105 270L145 285 160 305 205 335 175 355 115 345 75 315Z',x:132,y:318},
- {id:'zeeland',nl:'Zeeland',en:'Zeeland',capital:'middelburg',tier:3,color:'#86cfbf',d:'M45 326L90 320 112 337 82 349 38 342ZM35 351L78 350 105 365 75 378 30 370ZM55 382L103 378 120 391 82 402 45 395Z',x:73,y:369},
- {id:'noord_brabant',nl:'Noord-Brabant',en:'North Brabant',capital:'den_bosch',tier:1,color:'#e88c91',d:'M115 345L175 355 205 335 275 345 285 385 250 410 175 400 120 380Z',x:202,y:376},
- {id:'limburg',nl:'Limburg',en:'Limburg',capital:'maastricht',tier:1,color:'#e6c85f',d:'M250 410L285 385 300 420 285 450 295 485 278 520 255 490 260 455 242 430Z',x:272,y:458},
+ {id:"friesland",shape:"friesland",nl:"Friesland",en:"Friesland",capital:"leeuwarden",tier:2,color:"#93c5fd",x:518,y:166},
+ {id:"groningen",shape:"groningen",nl:"Groningen",en:"Groningen",capital:"groningen_city",tier:2,color:"#fca5a5",x:615,y:72},
+ {id:"drenthe",shape:"drenthe",nl:"Drenthe",en:"Drenthe",capital:"assen",tier:3,color:"#fde68a",x:611,y:222},
+ {id:"overijssel",shape:"overijssel",nl:"Overijssel",en:"Overijssel",capital:"zwolle",tier:2,color:"#c4b5fd",x:598,y:351},
+ {id:"flevoland",shape:"flevoland",nl:"Flevoland",en:"Flevoland",capital:"lelystad",tier:3,color:"#fdba74",x:465,y:272},
+ {id:"noord_holland",shape:"noord-holland",nl:"Noord-Holland",en:"North Holland",capital:"haarlem",tier:1,color:"#f9a8d4",x:308,y:244},
+ {id:"utrecht",shape:"utrecht",nl:"Utrecht",en:"Utrecht",capital:"utrecht_city",tier:1,color:"#d8b4fe",x:393,y:471},
+ {id:"gelderland",shape:"gelderland",nl:"Gelderland",en:"Gelderland",capital:"arnhem",tier:1,color:"#86efac",x:493,y:452},
+ {id:"zuid_holland",shape:"zuid-holland",nl:"Zuid-Holland",en:"South Holland",capital:"den_haag",tier:1,color:"#67e8f9",x:245,y:451},
+ {id:"zeeland",shape:"zeeland",nl:"Zeeland",en:"Zeeland",capital:"middelburg",tier:3,color:"#5eead4",x:132,y:698},
+ {id:"noord_brabant",shape:"noord-brabant",nl:"Noord-Brabant",en:"North Brabant",capital:"den_bosch",tier:1,color:"#fcd34d",x:367,y:599},
+ {id:"limburg",shape:"limburg",nl:"Limburg",en:"Limburg",capital:"maastricht",tier:1,color:"#a7f3d0",x:489,y:714},
]);
const NL_CITIES=Object.freeze([
- {id:'leeuwarden',nl:'Leeuwarden',en:'Leeuwarden',province:'friesland',capital:true,tier:2,x:150,y:72},
- {id:'groningen_city',nl:'Groningen',en:'Groningen',province:'groningen',capital:true,tier:2,x:281,y:66},
- {id:'assen',nl:'Assen',en:'Assen',province:'drenthe',capital:true,tier:3,x:264,y:140},
- {id:'zwolle',nl:'Zwolle',en:'Zwolle',province:'overijssel',capital:true,tier:2,x:266,y:218},
- {id:'lelystad',nl:'Lelystad',en:'Lelystad',province:'flevoland',capital:true,tier:3,x:195,y:203},
- {id:'haarlem',nl:'Haarlem',en:'Haarlem',province:'noord_holland',capital:true,tier:1,x:119,y:250},
- {id:'utrecht_city',nl:'Utrecht',en:'Utrecht',province:'utrecht',capital:true,tier:1,x:173,y:278},
- {id:'arnhem',nl:'Arnhem',en:'Arnhem',province:'gelderland',capital:true,tier:1,x:270,y:310},
- {id:'den_haag',nl:'Den Haag',en:'The Hague',province:'zuid_holland',capital:true,tier:1,x:96,y:312},
- {id:'middelburg',nl:'Middelburg',en:'Middelburg',province:'zeeland',capital:true,tier:3,x:69,y:374},
- {id:'den_bosch',nl:"'s-Hertogenbosch",en:"'s-Hertogenbosch",province:'noord_brabant',capital:true,tier:1,x:207,y:369},
- {id:'maastricht',nl:'Maastricht',en:'Maastricht',province:'limburg',capital:true,tier:1,x:275,y:493},
- {id:'amsterdam',nl:'Amsterdam',en:'Amsterdam',province:'noord_holland',capital:false,tier:1,x:139,y:246},
- {id:'rotterdam',nl:'Rotterdam',en:'Rotterdam',province:'zuid_holland',capital:false,tier:1,x:120,y:333},
- {id:'eindhoven',nl:'Eindhoven',en:'Eindhoven',province:'noord_brabant',capital:false,tier:1,x:230,y:395},
- {id:'nijmegen',nl:'Nijmegen',en:'Nijmegen',province:'gelderland',capital:false,tier:1,x:285,y:330},
- {id:'enschede',nl:'Enschede',en:'Enschede',province:'overijssel',capital:false,tier:2,x:315,y:230},
- {id:'alkmaar',nl:'Alkmaar',en:'Alkmaar',province:'noord_holland',capital:false,tier:2,x:105,y:185},
- {id:'breda',nl:'Breda',en:'Breda',province:'noord_brabant',capital:false,tier:3,x:158,y:383},
- {id:'tilburg',nl:'Tilburg',en:'Tilburg',province:'noord_brabant',capital:false,tier:3,x:193,y:391},
+ {id:"leeuwarden",nl:"Leeuwarden",en:"Leeuwarden",province:"friesland",capital:true,tier:2,x:467,y:119},
+ {id:"groningen_city",nl:"Groningen",en:"Groningen",province:"groningen",capital:true,tier:2,x:612,y:112},
+ {id:"assen",nl:"Assen",en:"Assen",province:"drenthe",capital:true,tier:3,x:612,y:175},
+ {id:"zwolle",nl:"Zwolle",en:"Zwolle",province:"overijssel",capital:true,tier:2,x:538,y:332},
+ {id:"lelystad",nl:"Lelystad",en:"Lelystad",province:"flevoland",capital:true,tier:3,x:417,y:327},
+ {id:"haarlem",nl:"Haarlem",en:"Haarlem",province:"noord_holland",capital:true,tier:1,x:261,y:371},
+ {id:"utrecht_city",nl:"Utrecht",en:"Utrecht",province:"utrecht",capital:true,tier:1,x:355,y:460},
+ {id:"arnhem",nl:"Arnhem",en:"Arnhem",province:"gelderland",capital:true,tier:1,x:500,y:486},
+ {id:"den_haag",nl:"Den Haag",en:"The Hague",province:"zuid_holland",capital:true,tier:1,x:192,y:460,side:"left"},
+ {id:"middelburg",nl:"Middelburg",en:"Middelburg",province:"zeeland",capital:true,tier:3,x:65,y:635},
+ {id:"den_bosch",nl:"'s-Hertogenbosch",en:"'s-Hertogenbosch",province:"noord_brabant",capital:true,tier:1,x:388,y:577},
+ {id:"maastricht",nl:"Maastricht",en:"Maastricht",province:"limburg",capital:true,tier:1,x:463,y:836},
+ {id:"amsterdam",nl:"Amsterdam",en:"Amsterdam",province:"noord_holland",capital:false,tier:1,x:311,y:373},
+ {id:"rotterdam",nl:"Rotterdam",en:"Rotterdam",province:"zuid_holland",capital:false,tier:1,x:234,y:508},
+ {id:"eindhoven",nl:"Eindhoven",en:"Eindhoven",province:"noord_brabant",capital:false,tier:1,x:424,y:653},
+ {id:"nijmegen",nl:"Nijmegen",en:"Nijmegen",province:"gelderland",capital:false,tier:1,x:497,y:536},
+ {id:"enschede",nl:"Enschede",en:"Enschede",province:"overijssel",capital:false,tier:2,x:688,y:409,side:"left"},
+ {id:"alkmaar",nl:"Alkmaar",en:"Alkmaar",province:"noord_holland",capital:false,tier:2,x:283,y:296},
+ {id:"breda",nl:"Breda",en:"Breda",province:"noord_brabant",capital:false,tier:3,x:286,y:611,side:"left"},
+ {id:"tilburg",nl:"Tilburg",en:"Tilburg",province:"noord_brabant",capital:false,tier:3,x:347,y:625},
]);
-const worldName=item=>LANG==='nl'?item.nl:item.en;
-const worldHint=item=>LANG==='nl'?item.hintNl:item.hintEn;
-const worldSvgUrl=svg=>'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg);
-const worldShuffle=items=>{const copy=[...items];for(let i=copy.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[copy[i],copy[j]]=[copy[j],copy[i]]}return copy};
-const worldSelect=(value,items)=>{const select=document.createElement('select');items.forEach(([id,key])=>select.add(new Option(T(key),id)));select.value=value;return select};
+const worldName=item=>LANG==="nl"?item.nl:item.en;
+const worldHint=item=>LANG==="nl"?item.hintNl:item.hintEn;
+const worldShuffle=items=>{
+ const copy=[...items];
+ for(let i=copy.length-1;i>0;i--){
+ const j=Math.floor(Math.random()*(i+1));
+ [copy[i],copy[j]]=[copy[j],copy[i]];
+ }
+ return copy;
+};
+const worldSelect=(value,items)=>{
+ const select=document.createElement("select");
+ items.forEach(([id,key])=>select.add(new Option(T(key),id)));
+ select.value=value;
+ return select;
+};
const worldLevelPool=(items,level)=>items.filter(item=>item.tier<=level);
-function treeSvg(item,view){
- const crowns={
- oak:'',
- birch:'',
- horse_chestnut:'',
- pine:'',
- beech:'',
- maple:'',
- willow:'',
- alder:'',
- };
- const leaves={
- oak:'',
- birch:'',
- horse_chestnut:'',
- pine:'',
- beech:'',
- maple:'',
- willow:'',
- alder:'',
- };
- if(view==='leaf')return worldSvgUrl(``);
- const trunk=item.id==='birch'?'#f2eee2':'#815939',marks=item.id==='birch'?'':'';
- return worldSvgUrl(``);
+function worldPhoto(item,type,view){
+ const kind=type==="birds"?"bird":view;
+ const media=WORLD_MEDIA[item.id]?.[kind];
+ const figure=document.createElement("figure");
+ figure.className="world-photo world-photo-"+kind;
+ const image=document.createElement("img");
+ image.className="world-picture";
+ image.alt=T(kind==="bird"?"worldPhotoBird":kind==="leaf"?"worldPhotoLeaf":"worldPhotoTree");
+ image.draggable=false;
+ image.loading="lazy";
+ image.decoding="async";
+ image.src=WORLD_MEDIA_ROOT+media.file;
+ const badge=document.createElement("span");
+ badge.className="world-photo-badge";
+ badge.textContent=T(kind==="bird"?"worldPhotoBird":kind==="leaf"?"worldPhotoLeaf":"worldPhotoTree");
+ 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(image,badge,credit);
+ return figure;
}
-function birdSvg(item){
- const extras={
- great_tit:'',
- blue_tit:'',
- house_sparrow:'',
- mallard:'',
- magpie:'',
- woodpecker:'',
- robin:'',
- blackbird:'',
- }[item.id]||'';
- const tail=item.id==='magpie'?'M82 111L26 158L101 132Z':'M84 112L45 142L98 130Z';
- return worldSvgUrl(``);
+function worldVisual(item,type,view){
+ const visual=document.createElement("div");
+ visual.className="world-visual "+type+(view==="mixed"?" mixed":"");
+ if(type==="trees"&&view==="mixed")visual.append(worldPhoto(item,type,"tree"),worldPhoto(item,type,"leaf"));
+ else visual.append(worldPhoto(item,type,view));
+ return visual;
}
-function worldPicture(item,type,view){const img=document.createElement('img');img.className='world-picture';img.alt=worldName(item);img.draggable=false;img.src=type==='trees'?treeSvg(item,view):birdSvg(item);return img}
-function worldVisual(item,type,view){const visual=document.createElement('div');visual.className='world-visual'+(view==='mixed'?' mixed':'');if(type==='trees'&&view==='mixed')visual.append(worldPicture(item,type,'tree'),worldPicture(item,type,'leaf'));else visual.append(worldPicture(item,type,view));return visual}
+function worldIdentity(item){
+ const identity=document.createElement("div");
+ identity.className="world-identity";
+ const name=document.createElement("h3");
+ name.textContent=worldName(item);
+ const latin=document.createElement("em");
+ latin.textContent=item.latin;
+ identity.append(name,latin);
+ return identity;
+}
function mountWorldQuiz(root,init,config){
- const S=Object.assign({activity:'quiz',level:1,view:config.type==='trees'?'mixed':'bird',score:0,streak:0,target:'',taskView:'',options:[],solved:false,feedback:''},init||{});S.level=Math.max(1,Math.min(3,+S.level||1));
- const activity=worldSelect(S.activity,[['learn','worldLearn'],['quiz','worldQuiz']]),level=worldSelect(String(S.level),[['1','lvl1'],['2','lvl2'],['3','lvl3']]),stage=document.createElement('div'),settings=[field(T('worldActivity'),activity),field(T('worldLevel'),level)];
- let view;if(config.type==='trees'){view=worldSelect(S.view,[['tree','worldTreeWhole'],['leaf','worldTreeLeaf'],['mixed','worldTreeMixed']]);settings.push(field(T('worldTreeView'),view))}
- const pool=()=>worldLevelPool(config.items,S.level),current=()=>config.items.find(item=>item.id===S.target)||pool()[0];
- const newTask=()=>{const items=pool(),previous=S.target;let target=items[Math.floor(Math.random()*items.length)];if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)];S.target=target.id;S.taskView=config.type==='trees'?(S.view==='mixed'?(Math.random()<.5?'tree':'leaf'):S.view):'bird';S.options=worldShuffle([target,...worldShuffle(items.filter(item=>item.id!==target.id)).slice(0,3)]).map(item=>item.id);S.solved=false;S.feedback='';draw()};
- const answer=id=>{if(S.solved)return;if(id===S.target){S.solved=true;S.feedback='good';S.score++;S.streak++;sndGood()}else{S.feedback='bad';S.streak=0;sndBad()}draw()};
- const gallery=()=>{const grid=document.createElement('div');grid.className='world-gallery';pool().forEach(item=>{const card=document.createElement('article');card.className='world-specimen';card.append(worldVisual(item,config.type,config.type==='trees'?S.view:'bird'));const name=document.createElement('h3');name.textContent=worldName(item);const hint=document.createElement('p');hint.textContent=worldHint(item);card.append(name,hint);grid.append(card)});return grid};
- function draw(){S.activity=activity.value;S.level=+level.value;if(view)S.view=view.value;stage.className='world-app';stage.innerHTML='';const top=document.createElement('div');top.className='world-top';const title=document.createElement('strong');title.textContent=T('wg_'+config.type);const score=document.createElement('span');score.textContent=T('worldScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(title,score);stage.append(top);if(S.activity==='learn'){const intro=document.createElement('p');intro.className='world-intro';intro.textContent=T('worldExploreHint');stage.append(intro,gallery());return}const item=current(),card=document.createElement('section');card.className='world-task-card';const question=document.createElement('h3');question.textContent=T(config.type==='birds'?'worldQuestionBird':S.taskView==='leaf'?'worldQuestionLeaf':'worldQuestionTree');card.append(question,worldVisual(item,config.type,S.taskView));const answers=document.createElement('div');answers.className='world-answers';S.options.forEach(id=>{const option=config.items.find(entry=>entry.id===id);if(!option)return;const button=btn(worldName(option),()=>answer(id));if(S.solved&&id===S.target)button.classList.add('correct');answers.append(button)});card.append(answers);const feedback=document.createElement('p');feedback.className='world-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'worldCorrect':'worldTryAgain');card.append(feedback);const actions=document.createElement('div');actions.className='world-actions';actions.append(btn(T('worldNext'),newTask,'ghost'));card.append(actions);if(S.solved){const fact=document.createElement('p');fact.className='world-fact';fact.textContent=worldHint(item);card.append(fact)}stage.append(card)}
- activity.onchange=()=>{S.activity=activity.value;if(S.activity==='quiz')newTask();else draw()};level.onchange=()=>{S.level=+level.value;if(S.activity==='quiz')newTask();else draw()};if(view)view.onchange=()=>{S.view=view.value;if(S.activity==='quiz')newTask();else draw()};learningShell(root,settings,stage);if(S.activity==='quiz')newTask();else draw();return{getState:()=>({...S,options:[...S.options]})};
+ const S=Object.assign({
+ activity:"quiz",level:1,view:config.type==="trees"?"mixed":"bird",
+ score:0,streak:0,target:"",taskView:"",options:[],solved:false,feedback:"",
+ },init||{});
+ S.level=Math.max(1,Math.min(3,+S.level||1));
+ const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"]]);
+ 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;
+ if(config.type==="trees"){
+ view=worldSelect(S.view,[["tree","worldTreeWhole"],["leaf","worldTreeLeaf"],["mixed","worldTreeMixed"]]);
+ settings.push(field(T("worldTreeView"),view));
+ }
+ const pool=()=>worldLevelPool(config.items,S.level);
+ const current=()=>config.items.find(item=>item.id===S.target)||pool()[0];
+ const newTask=()=>{
+ const items=pool(),previous=S.target;
+ let target=items[Math.floor(Math.random()*items.length)];
+ if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)];
+ S.target=target.id;
+ S.taskView=config.type==="trees"?(S.view==="mixed"?(Math.random()<.5?"tree":"leaf"):S.view):"bird";
+ S.options=worldShuffle([target,...worldShuffle(items.filter(item=>item.id!==target.id)).slice(0,3)]).map(item=>item.id);
+ S.solved=false;
+ S.feedback="";
+ draw();
+ };
+ const answer=id=>{
+ if(S.solved)return;
+ if(id===S.target){
+ S.solved=true;S.feedback="good";S.score++;S.streak++;sndGood();
+ }else{
+ S.feedback="bad";S.streak=0;sndBad();
+ }
+ draw();
+ };
+ const gallery=()=>{
+ const grid=document.createElement("div");
+ grid.className="world-gallery";
+ pool().forEach(item=>{
+ const card=document.createElement("article");
+ card.className="world-specimen";
+ card.append(worldVisual(item,config.type,config.type==="trees"?S.view:"bird"),worldIdentity(item));
+ const hint=document.createElement("p");
+ hint.textContent=worldHint(item);
+ card.append(hint);
+ grid.append(card);
+ });
+ return grid;
+ };
+ function draw(){
+ S.activity=activity.value;
+ S.level=+level.value;
+ if(view)S.view=view.value;
+ stage.className="world-app";
+ stage.innerHTML="";
+ const top=document.createElement("div");
+ top.className="world-top";
+ const title=document.createElement("strong");
+ title.textContent=T("wg_"+config.type);
+ const score=document.createElement("span");
+ score.textContent=T("worldScore").replace("{score}",S.score).replace("{streak}",S.streak);
+ top.append(title,score);
+ stage.append(top);
+ if(S.activity==="learn"){
+ const intro=document.createElement("p");
+ intro.className="world-intro";
+ intro.textContent=T("worldExploreHint");
+ stage.append(intro,gallery());
+ return;
+ }
+ const item=current(),card=document.createElement("section");
+ card.className="world-task-card";
+ const question=document.createElement("h3");
+ question.textContent=T(config.type==="birds"?"worldQuestionBird":S.taskView==="leaf"?"worldQuestionLeaf":"worldQuestionTree");
+ card.append(question,worldVisual(item,config.type,S.taskView));
+ const answers=document.createElement("div");
+ answers.className="world-answers";
+ S.options.forEach(id=>{
+ const option=config.items.find(entry=>entry.id===id);
+ if(!option)return;
+ const button=btn(worldName(option),()=>answer(id));
+ if(S.solved&&id===S.target)button.classList.add("correct");
+ answers.append(button);
+ });
+ card.append(answers);
+ const feedback=document.createElement("p");
+ feedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":"");
+ feedback.setAttribute("role","status");
+ if(S.feedback)feedback.textContent=T(S.feedback==="good"?"worldCorrect":"worldTryAgain");
+ card.append(feedback);
+ const actions=document.createElement("div");
+ actions.className="world-actions";
+ actions.append(btn(T("worldNext"),newTask,"ghost"));
+ card.append(actions);
+ if(S.solved){
+ const fact=document.createElement("div");
+ fact.className="world-fact";
+ fact.append(worldIdentity(item));
+ const hint=document.createElement("p");
+ hint.textContent=worldHint(item);
+ fact.append(hint);
+ card.append(fact);
+ }
+ stage.append(card);
+ }
+ activity.onchange=()=>{S.activity=activity.value;if(S.activity==="quiz")newTask();else draw()};
+ level.onchange=()=>{S.level=+level.value;if(S.activity==="quiz")newTask();else draw()};
+ if(view)view.onchange=()=>{S.view=view.value;if(S.activity==="quiz")newTask();else draw()};
+ learningShell(root,settings,stage);
+ if(S.activity==="quiz")newTask();else draw();
+ return{getState:()=>({...S,options:[...S.options]})};
+}
+
+function mountTrees(root,init){return mountWorldQuiz(root,init,{type:"trees",items:WORLD_TREES})}
+function mountBirds(root,init){return mountWorldQuiz(root,init,{type:"birds",items:WORLD_BIRDS})}
+
+const SVG_NS="http://www.w3.org/2000/svg";
+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";
+function svgNode(name,attributes={}){
+ const node=document.createElementNS(SVG_NS,name);
+ Object.entries(attributes).forEach(([key,value])=>node.setAttribute(key,value));
+ return node;
+}
+function svgAction(node,label,action){
+ node.setAttribute("role","button");
+ node.setAttribute("tabindex","0");
+ node.setAttribute("aria-label",label);
+ node.addEventListener("click",action);
+ node.addEventListener("keydown",event=>{
+ if(event.key==="Enter"||event.key===" "){
+ event.preventDefault();
+ action();
+ }
+ });
}
-function mountTrees(root,init){return mountWorldQuiz(root,init,{type:'trees',items:WORLD_TREES})}
-function mountBirds(root,init){return mountWorldQuiz(root,init,{type:'birds',items:WORLD_BIRDS})}
-const SVG_NS='http://www.w3.org/2000/svg';
-function svgNode(name,attributes={}){const node=document.createElementNS(SVG_NS,name);Object.entries(attributes).forEach(([key,value])=>node.setAttribute(key,value));return node}
-function svgAction(node,label,action){node.setAttribute('role','button');node.setAttribute('tabindex','0');node.setAttribute('aria-label',label);node.addEventListener('click',action);node.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();action()}})}
function makeTopoMap(mode,pool,onPick,showLabels,picked,target){
- const wrap=document.createElement('div');wrap.className='topo-map-wrap';const svg=svgNode('svg',{class:'topo-map',viewBox:'20 20 330 510',role:'img','aria-label':T('topoMapLabel')});svg.append(svgNode('rect',{x:20,y:20,width:330,height:510,rx:22,class:'topo-water'}));
- NL_PROVINCES.forEach(province=>{const path=svgNode('path',{d:province.d,fill:province.color,class:'topo-province'+(province.id===target?' target':'')+(province.id===picked?' picked':''),'data-id':province.id});const title=svgNode('title');title.textContent=worldName(province);path.append(title);if(onPick&&mode==='provinces')svgAction(path,worldName(province),()=>onPick(province.id));svg.append(path);if(showLabels&&mode==='provinces'&&pool.some(item=>item.id===province.id)){const label=svgNode('text',{x:province.x,y:province.y,class:'topo-label'});label.textContent=worldName(province);svg.append(label)}});
- if(mode!=='provinces')pool.forEach(city=>{const group=svgNode('g',{class:'topo-marker'+(city.id===target?' target':'')+(city.id===picked?' picked':'')});const hit=svgNode('circle',{cx:city.x,cy:city.y,r:15,class:'topo-hit'}),dot=svgNode('circle',{cx:city.x,cy:city.y,r:6,class:'topo-dot'});group.append(hit,dot);if(showLabels){const label=svgNode('text',{x:city.x+9,y:city.y-8,class:'topo-city-label'});label.textContent=worldName(city);group.append(label)}if(onPick)svgAction(group,worldName(city),()=>onPick(city.id));svg.append(group)});wrap.append(svg);return wrap;
+ const wrap=document.createElement("div");
+ wrap.className="topo-map-wrap";
+ const svg=svgNode("svg",{
+ class:"topo-map",viewBox:"0 0 743 881",
+ 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 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);
+ svg.append(label);
+ }
+ });
+ if(mode!=="provinces")pool.forEach(city=>{
+ const group=svgNode("g",{
+ class:"topo-marker"+(city.id===target?" target":"")+(city.id===picked?" picked":""),
+ });
+ const hit=svgNode("circle",{cx:city.x,cy:city.y,r:24,class:"topo-hit"});
+ const dot=svgNode("circle",{cx:city.x,cy:city.y,r:10,class:"topo-dot"});
+ group.append(hit,dot);
+ if(showLabels){
+ const left=city.side==="left";
+ const label=svgNode("text",{
+ x:city.x+(left?-16:16),y:city.y-13,
+ class:"topo-city-label"+(left?" left":""),
+ });
+ label.textContent=worldName(city);
+ group.append(label);
+ }
+ if(onPick)svgAction(group,worldName(city),()=>onPick(city.id));
+ svg.append(group);
+ });
+ const source=document.createElement("a");
+ source.className="topo-source";
+ source.href=TOPO_MAP_SOURCE;
+ source.target="_blank";
+ source.rel="noopener noreferrer";
+ source.textContent=T("topoMapSource");
+ wrap.append(svg,source);
+ return wrap;
}
+
function mountTopography(root,init){
- const S=Object.assign({activity:'quiz',mode:'provinces',level:1,score:0,streak:0,target:'',picked:'',solved:false,feedback:''},init||{});S.level=Math.max(1,Math.min(3,+S.level||1));
- const activity=worldSelect(S.activity,[['learn','worldLearn'],['quiz','worldQuiz']]),mode=worldSelect(S.mode,[['provinces','topoProvinces'],['capitals','topoCapitals'],['cities','topoCities']]),level=worldSelect(String(S.level),[['1','lvl1'],['2','lvl2'],['3','lvl3']]),stage=document.createElement('div');
- const pool=()=>S.mode==='provinces'?NL_PROVINCES.filter(item=>item.tier<=S.level):S.mode==='capitals'?NL_CITIES.filter(item=>item.capital&&item.tier<=S.level):NL_CITIES.filter(item=>!item.capital&&item.tier<=S.level),current=()=>pool().find(item=>item.id===S.target)||pool()[0];
- const newTask=()=>{const items=pool(),previous=S.target;let target=items[Math.floor(Math.random()*items.length)];if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)];S.target=target.id;S.picked='';S.solved=false;S.feedback='';draw()};
- const answer=id=>{if(S.solved)return;S.picked=id;if(id===S.target){S.solved=true;S.feedback='good';S.score++;S.streak++;sndGood()}else{S.feedback='bad';S.streak=0;sndBad()}draw()};
- function draw(){S.activity=activity.value;S.mode=mode.value;S.level=+level.value;stage.className='world-app topo-app';stage.innerHTML='';const top=document.createElement('div');top.className='world-top';const title=document.createElement('strong');title.textContent=T('wg_topography');const score=document.createElement('span');score.textContent=T('worldScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(title,score);stage.append(top);const card=document.createElement('section');card.className='world-task-card topo-card';if(S.activity==='learn'){const intro=document.createElement('p');intro.className='world-intro';intro.textContent=T('topoExploreHint');card.append(intro,makeTopoMap(S.mode,pool(),null,true,'',''))}else{const item=current(),question=document.createElement('h3');question.textContent=T(S.mode==='provinces'?'topoFindProvince':S.mode==='capitals'?'topoFindCapital':'topoFindCity').replace('{name}',worldName(item));card.append(question,makeTopoMap(S.mode,pool(),answer,S.solved,S.picked,S.solved?S.target:''));const feedback=document.createElement('p');feedback.className='world-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'worldCorrect':'worldTryAgain');const actions=document.createElement('div');actions.className='world-actions';actions.append(btn(T('worldNext'),newTask,'ghost'));card.append(feedback,actions)}stage.append(card)}
- activity.onchange=()=>{S.activity=activity.value;if(S.activity==='quiz')newTask();else draw()};mode.onchange=()=>{S.mode=mode.value;if(S.activity==='quiz')newTask();else draw()};level.onchange=()=>{S.level=+level.value;if(S.activity==='quiz')newTask();else draw()};learningShell(root,[field(T('worldActivity'),activity),field(T('topoMode'),mode),field(T('worldLevel'),level)],stage);if(S.activity==='quiz')newTask();else draw();return{getState:()=>({...S})};
+ const S=Object.assign({
+ activity:"quiz",mode:"provinces",level:1,score:0,streak:0,
+ target:"",picked:"",solved:false,feedback:"",
+ },init||{});
+ S.level=Math.max(1,Math.min(3,+S.level||1));
+ const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"]]);
+ const mode=worldSelect(S.mode,[["provinces","topoProvinces"],["capitals","topoCapitals"],["cities","topoCities"]]);
+ 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 current=()=>pool().find(item=>item.id===S.target)||pool()[0];
+ const newTask=()=>{
+ const items=pool(),previous=S.target;
+ let target=items[Math.floor(Math.random()*items.length)];
+ if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)];
+ S.target=target.id;S.picked="";S.solved=false;S.feedback="";
+ draw();
+ };
+ const answer=id=>{
+ if(S.solved)return;
+ S.picked=id;
+ if(id===S.target){
+ S.solved=true;S.feedback="good";S.score++;S.streak++;sndGood();
+ }else{
+ S.feedback="bad";S.streak=0;sndBad();
+ }
+ draw();
+ };
+ function draw(){
+ S.activity=activity.value;S.mode=mode.value;S.level=+level.value;
+ stage.className="world-app topo-app";
+ stage.innerHTML="";
+ const top=document.createElement("div");
+ top.className="world-top";
+ const title=document.createElement("strong");
+ title.textContent=T("wg_topography");
+ const score=document.createElement("span");
+ score.textContent=T("worldScore").replace("{score}",S.score).replace("{streak}",S.streak);
+ top.append(title,score);
+ stage.append(top);
+ const card=document.createElement("section");
+ card.className="world-task-card topo-card";
+ if(S.activity==="learn"){
+ const intro=document.createElement("p");
+ intro.className="world-intro";
+ intro.textContent=T("topoExploreHint");
+ card.append(intro,makeTopoMap(S.mode,pool(),null,true,"",""));
+ }else{
+ const item=current(),question=document.createElement("h3");
+ question.textContent=T(S.mode==="provinces"?"topoFindProvince":S.mode==="capitals"?"topoFindCapital":"topoFindCity").replace("{name}",worldName(item));
+ card.append(question,makeTopoMap(S.mode,pool(),answer,S.solved,S.picked,S.solved?S.target:""));
+ const feedback=document.createElement("p");
+ feedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":"");
+ feedback.setAttribute("role","status");
+ if(S.feedback)feedback.textContent=T(S.feedback==="good"?"worldCorrect":"worldTryAgain");
+ const actions=document.createElement("div");
+ actions.className="world-actions";
+ actions.append(btn(T("worldNext"),newTask,"ghost"));
+ card.append(feedback,actions);
+ }
+ stage.append(card);
+ }
+ activity.onchange=()=>{S.activity=activity.value;if(S.activity==="quiz")newTask();else draw()};
+ mode.onchange=()=>{S.mode=mode.value;if(S.activity==="quiz")newTask();else draw()};
+ level.onchange=()=>{S.level=+level.value;if(S.activity==="quiz")newTask();else draw()};
+ learningShell(root,[
+ field(T("worldActivity"),activity),
+ field(T("topoMode"),mode),
+ field(T("worldLevel"),level),
+ ],stage);
+ if(S.activity==="quiz")newTask();else draw();
+ return{getState:()=>({...S})};
}
diff --git a/test/widgets.test.js b/test/widgets.test.js
index d939ce1..4371971 100644
--- a/test/widgets.test.js
+++ b/test/widgets.test.js
@@ -66,16 +66,21 @@ 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','topoProvinces','topoCapitals','topoCities','topoFindProvince','topoFindCapital','topoFindCity'])assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
+ 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);
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 volledige soorten en Nederlandse topografie', async()=>{
- const source=await readFile('public/js/widgets/world.js','utf8'),context={};
- runInNewContext(source+';globalThis.__world={trees:WORLD_TREES,birds:WORLD_BIRDS,provinces:NL_PROVINCES,cities:NL_CITIES}',context);
+test('wereldoriëntatiedata bevat echte soortmedia en volledige Nederlandse topografie', async()=>{
+ const [source,map,attribution]=await Promise.all([
+ readFile('public/js/widgets/world.js','utf8'),
+ readFile('public/img/world/netherlands-provinces.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);
const data=JSON.parse(JSON.stringify(context.__world));
assert.equal(data.trees.length,8);
assert.equal(data.birds.length,8);
@@ -83,13 +88,35 @@ test('wereldoriëntatiedata bevat volledige soorten en Nederlandse topografie',
assert.equal(data.cities.filter(city=>city.capital).length,12);
assert.equal(data.cities.filter(city=>!city.capital).length,8);
for(const collection of [data.trees,data.birds,data.provinces,data.cities])assert.equal(new Set(collection.map(item=>item.id)).size,collection.length);
- for(const item of [...data.trees,...data.birds])assert.ok(item.nl&&item.en&&item.hintNl&&item.hintEn&&item.tier>=1&&item.tier<=3,item.id);
+ 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){
+ assert.ok(data.media[tree.id]?.tree?.file&&data.media[tree.id]?.leaf?.file,tree.id);
+ assetPaths.push(data.media[tree.id].tree.file,data.media[tree.id].leaf.file);
+ }
+ for(const bird of data.birds){
+ assert.ok(data.media[bird.id]?.bird?.file,bird.id);
+ assetPaths.push(data.media[bird.id].bird.file);
+ }
+ assert.equal(new Set(assetPaths).size,24);
+ for(const relativePath of assetPaths){
+ const image=await readFile('public/img/world/'+relativePath);
+ assert.ok(image.length>10_000,relativePath+' is geen bruikbare foto');
+ assert.equal(image.subarray(0,4).toString(),'RIFF',relativePath);
+ assert.equal(image.subarray(8,12).toString(),'WEBP',relativePath);
+ assert.ok(attribution.includes('`'+relativePath+'`'),relativePath+' mist bronvermelding');
+ }
const cities=new Map(data.cities.map(city=>[city.id,city]));
const capitals=Object.fromEntries(data.provinces.map(province=>[province.nl,cities.get(province.capital)?.nl]));
assert.deepEqual(capitals,{
Friesland:'Leeuwarden',Groningen:'Groningen',Drenthe:'Assen',Overijssel:'Zwolle',Flevoland:'Lelystad','Noord-Holland':'Haarlem',Utrecht:'Utrecht',Gelderland:'Arnhem','Zuid-Holland':'Den Haag',Zeeland:'Middelburg','Noord-Brabant':"'s-Hertogenbosch",Limburg:'Maastricht',
});
- for(const province of data.provinces)assert.ok(province.d&&Number.isFinite(province.x)&&Number.isFinite(province.y)&&cities.has(province.capital),province.id);
+ 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.equal((map.match(/{
@@ -310,6 +337,8 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
'.edu-stage{align-items:safe center;justify-content:safe center',
'.money-task-card,.clock-task-card,.world-task-card',
'.world-gallery{grid-template-columns:1fr}',
+ '.world-photo-credit',
+ '.topo-map-wrap{padding:6px}',
]){
assert.ok(compact.includes(selector),selector+' mist in de compacte containerregels');
}