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(`${leaves[item.id]}`); - const trunk=item.id==='birch'?'#f2eee2':'#815939',marks=item.id==='birch'?'':''; - return worldSvgUrl(`${marks}${crowns[item.id]}`); +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(`${extras}`); +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'); }