All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 30s
663 lines
34 KiB
JavaScript
663 lines
34 KiB
JavaScript
/* 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:"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",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",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: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 NL_REGIONS=Object.freeze([
|
|
{id:"north",nl:"Noord-Nederland",en:"Northern Netherlands",tier:1,color:"#60a5fa",provinces:["friesland","groningen","drenthe"],x:565,y:172},
|
|
{id:"east",nl:"Oost-Nederland",en:"Eastern Netherlands",tier:1,color:"#a78bfa",provinces:["overijssel","gelderland","flevoland"],x:548,y:406},
|
|
{id:"west",nl:"West-Nederland",en:"Western Netherlands",tier:1,color:"#22d3ee",provinces:["noord_holland","zuid_holland","utrecht","zeeland"],x:252,y:454},
|
|
{id:"south",nl:"Zuid-Nederland",en:"Southern Netherlands",tier:1,color:"#fbbf24",provinces:["noord_brabant","limburg"],x:417,y:648},
|
|
]);
|
|
|
|
const NL_RIVERS=Object.freeze([
|
|
{id:"rijn",nl:"Rijn",en:"Rhine",tier:1,color:"#2563eb",labelX:399,labelY:477,path:"M 567 510 C 535 508 515 493 489 491 C 452 488 429 472 394 474 C 353 476 322 494 280 502 C 257 507 238 508 218 514"},
|
|
{id:"maas",nl:"Maas",en:"Meuse",tier:1,color:"#0891b2",labelX:401,labelY:581,path:"M 463 850 C 445 817 469 773 490 730 C 511 688 536 639 519 596 C 504 565 472 556 439 570 C 398 588 362 574 320 580 C 280 586 247 578 211 566"},
|
|
{id:"ijssel",nl:"IJssel",en:"IJssel",tier:2,color:"#0ea5e9",labelX:536,labelY:389,path:"M 497 489 C 516 459 525 426 526 390 C 527 356 520 329 502 306 C 484 282 463 270 447 250"},
|
|
{id:"waal",nl:"Waal",en:"Waal",tier:2,color:"#1d4ed8",labelX:421,labelY:548,path:"M 557 519 C 527 530 503 539 470 548 C 435 558 406 562 373 553 C 340 544 310 548 280 558 C 253 566 230 560 210 550"},
|
|
{id:"schelde",nl:"Schelde",en:"Scheldt",tier:3,color:"#0284c7",labelX:139,labelY:641,path:"M 41 660 C 82 654 119 649 151 642 C 176 637 191 624 207 609"},
|
|
]);
|
|
|
|
const WORLD_CONTINENTS=Object.freeze([
|
|
{id:"europe",shape:"europe",nl:"Europa",en:"Europe",tier:1,color:"#fbbf24",x:382,y:96},
|
|
{id:"africa",shape:"africa",nl:"Afrika",en:"Africa",tier:1,color:"#fb923c",x:383,y:190},
|
|
{id:"asia",shape:"asia",nl:"Azië",en:"Asia",tier:1,color:"#f87171",x:505,y:112},
|
|
{id:"north_america",shape:"north-america",nl:"Noord-Amerika",en:"North America",tier:2,color:"#60a5fa",x:154,y:112},
|
|
{id:"south_america",shape:"south-america",nl:"Zuid-Amerika",en:"South America",tier:2,color:"#34d399",x:245,y:252},
|
|
{id:"oceania",shape:"oceania",nl:"Oceanië",en:"Oceania",tier:2,color:"#c084fc",x:593,y:248},
|
|
{id:"antarctica",shape:"antarctica",nl:"Antarctica",en:"Antarctica",tier:3,color:"#cbd5e1",x:360,y:337},
|
|
]);
|
|
|
|
const worldName=item=>LANG==="nl"?item.nl:item.en;
|
|
const worldHint=item=>LANG==="nl"?item.hintNl:item.hintEn;
|
|
const worldShuffle=items=>{
|
|
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 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 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 worldPhotoOption(item,type,view,index,onChoose,status){
|
|
const kind=type==="birds"?"bird":view;
|
|
const media=WORLD_MEDIA[item.id]?.[kind];
|
|
const figure=document.createElement("figure");
|
|
figure.className="world-photo-choice"+(status?" "+status:"");
|
|
const button=document.createElement("button");
|
|
button.type="button";
|
|
button.className="world-image-button";
|
|
button.setAttribute("aria-label",T("worldPhotoOption").replace("{number}",index+1));
|
|
const image=document.createElement("img");
|
|
image.className="world-picture";
|
|
image.alt="";
|
|
image.draggable=false;
|
|
image.loading="lazy";
|
|
image.decoding="async";
|
|
image.src=WORLD_MEDIA_ROOT+media.file;
|
|
const number=document.createElement("span");
|
|
number.className="world-choice-number";
|
|
number.textContent=String(index+1);
|
|
button.append(image,number);
|
|
button.onclick=onChoose;
|
|
const credit=document.createElement("a");
|
|
credit.className="world-photo-credit";
|
|
credit.href=media.source;
|
|
credit.target="_blank";
|
|
credit.rel="noopener noreferrer";
|
|
credit.textContent=T("worldPhotoCredit").replace("{author}",media.author).replace("{license}",media.license);
|
|
figure.append(button,credit);
|
|
return figure;
|
|
}
|
|
|
|
function worldIdentity(item){
|
|
const identity=document.createElement("div");
|
|
identity.className="world-identity";
|
|
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 exerciseOptions=config.type==="trees"
|
|
?[["recognize","worldExerciseRecognize"],["pair","worldExercisePair"],["clue","worldExerciseClue"],["photo","worldExercisePhoto"],["latin","worldExerciseLatin"]]
|
|
:[["recognize","worldExerciseRecognize"],["clue","worldExerciseClue"],["photo","worldExercisePhoto"],["latin","worldExerciseLatin"]];
|
|
const S=Object.assign({
|
|
activity:"quiz",exercise:"recognize",level:1,view:config.type==="trees"?"mixed":"bird",
|
|
score:0,streak:0,target:"",taskView:"",options:[],picked:"",solved:false,feedback:"",
|
|
},init||{});
|
|
S.level=Math.max(1,Math.min(3,+S.level||1));
|
|
if(!exerciseOptions.some(([id])=>id===S.exercise))S.exercise="recognize";
|
|
const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"]]);
|
|
const exercise=worldSelect(S.exercise,exerciseOptions);
|
|
const level=worldSelect(String(S.level),[["1","lvl1"],["2","lvl2"],["3","lvl3"]]);
|
|
const stage=document.createElement("div");
|
|
const exerciseSetting=field(T("worldExercise"),exercise);
|
|
const settings=[field(T("worldActivity"),activity),exerciseSetting,field(T("worldLevel"),level)];
|
|
let view,viewSetting;
|
|
if(config.type==="trees"){
|
|
view=worldSelect(S.view,[["tree","worldTreeWhole"],["leaf","worldTreeLeaf"],["mixed","worldTreeMixed"]]);
|
|
viewSetting=field(T("worldTreeView"),view);
|
|
settings.push(viewSetting);
|
|
}
|
|
const pool=()=>worldLevelPool(config.items,S.level);
|
|
const current=()=>config.items.find(item=>item.id===S.target)||pool()[0];
|
|
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.exercise==="pair"?"mixed":S.view==="mixed"?(Math.random()<.5?"tree":"leaf"):S.view
|
|
:"bird";
|
|
S.options=worldShuffle([target,...worldShuffle(items.filter(item=>item.id!==target.id)).slice(0,3)]).map(item=>item.id);
|
|
S.picked="";
|
|
S.solved=false;
|
|
S.feedback="";
|
|
draw();
|
|
};
|
|
const answer=id=>{
|
|
if(S.solved)return;
|
|
S.picked=id;
|
|
if(id===S.target){
|
|
S.solved=true;S.feedback="good";S.score++;S.streak++;sndGood();
|
|
}else{
|
|
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.exercise=exercise.value;
|
|
S.level=+level.value;
|
|
if(view)S.view=view.value;
|
|
exerciseSetting.hidden=S.activity==="learn";
|
|
if(viewSetting)viewSetting.hidden=S.activity==="quiz"&&!["recognize","photo"].includes(S.exercise);
|
|
stage.className="world-app";
|
|
stage.innerHTML="";
|
|
const top=document.createElement("div");
|
|
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");
|
|
if(S.exercise==="photo"){
|
|
question.textContent=T(config.type==="birds"?"worldFindBirdPhoto":"worldFindTreePhoto").replace("{name}",worldName(item));
|
|
card.append(question);
|
|
const answers=document.createElement("div");
|
|
answers.className="world-photo-answers";
|
|
S.options.forEach((id,index)=>{
|
|
const option=config.items.find(entry=>entry.id===id);
|
|
if(!option)return;
|
|
const status=S.solved&&id===S.target?"correct":S.feedback==="bad"&&id===S.picked?"wrong":"";
|
|
const optionView=config.type==="trees"?(S.taskView==="mixed"?"tree":S.taskView):"bird";
|
|
answers.append(worldPhotoOption(option,config.type,optionView,index,()=>answer(id),status));
|
|
});
|
|
card.append(answers);
|
|
}else{
|
|
const prompt=document.createElement("div");
|
|
if(S.exercise==="clue"){
|
|
question.textContent=T("worldQuestionClue");
|
|
prompt.className="world-clue";
|
|
prompt.textContent=worldHint(item);
|
|
}else if(S.exercise==="latin"){
|
|
question.textContent=T("worldQuestionLatin");
|
|
prompt.className="world-clue world-clue-latin";
|
|
prompt.textContent=item.latin;
|
|
}else if(S.exercise==="pair"){
|
|
question.textContent=T("worldQuestionPair");
|
|
prompt.append(worldVisual(item,config.type,"mixed"));
|
|
}else{
|
|
question.textContent=T(config.type==="birds"?"worldQuestionBird":S.taskView==="leaf"?"worldQuestionLeaf":"worldQuestionTree");
|
|
prompt.append(worldVisual(item,config.type,S.taskView));
|
|
}
|
|
card.append(question,prompt);
|
|
const answers=document.createElement("div");
|
|
answers.className="world-answers";
|
|
S.options.forEach(id=>{
|
|
const option=config.items.find(entry=>entry.id===id);
|
|
if(!option)return;
|
|
const button=btn(worldName(option),()=>answer(id));
|
|
if(S.solved&&id===S.target)button.classList.add("correct");
|
|
else if(S.feedback==="bad"&&id===S.picked)button.classList.add("wrong");
|
|
answers.append(button);
|
|
});
|
|
card.append(answers);
|
|
}
|
|
const feedback=document.createElement("p");
|
|
feedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":"");
|
|
feedback.setAttribute("role","status");
|
|
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()};
|
|
exercise.onchange=()=>{S.exercise=exercise.value;if(S.activity==="quiz")newTask();else draw()};
|
|
level.onchange=()=>{S.level=+level.value;if(S.activity==="quiz")newTask();else draw()};
|
|
if(view)view.onchange=()=>{S.view=view.value;if(S.activity==="quiz")newTask();else draw()};
|
|
learningShell(root,settings,stage);
|
|
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";
|
|
const TOPO_WORLD_ASSET=WORLD_MEDIA_ROOT+"world-continents.svg";
|
|
const TOPO_WORLD_SOURCE="https://www.naturalearthdata.com/downloads/";
|
|
function svgNode(name,attributes={}){
|
|
const node=document.createElementNS(SVG_NS,name);
|
|
Object.entries(attributes).forEach(([key,value])=>node.setAttribute(key,value));
|
|
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:"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"}));
|
|
const active=new Set(pool.map(item=>item.id));
|
|
if(mode==="regions"){
|
|
NL_REGIONS.forEach(region=>{
|
|
const group=svgNode("g",{
|
|
class:"topo-region"+(region.id===target?" target":"")+(region.id===picked?" picked":""),
|
|
"data-id":region.id,
|
|
});
|
|
region.provinces.forEach(provinceId=>{
|
|
const province=NL_PROVINCES.find(item=>item.id===provinceId);
|
|
if(province)group.append(svgNode("use",{href:TOPO_MAP_ASSET+"#"+province.shape,fill:region.color,class:"topo-province"}));
|
|
});
|
|
const title=svgNode("title");
|
|
title.textContent=worldName(region);
|
|
group.append(title);
|
|
if(onPick&&active.has(region.id))svgAction(group,worldName(region),()=>onPick(region.id));
|
|
svg.append(group);
|
|
if(showLabels&&active.has(region.id)){
|
|
const label=svgNode("text",{x:region.x,y:region.y,class:"topo-label topo-region-label"});
|
|
label.textContent=worldName(region);
|
|
svg.append(label);
|
|
}
|
|
});
|
|
}else{
|
|
NL_PROVINCES.forEach(province=>{
|
|
const selectable=mode==="provinces"&&active.has(province.id);
|
|
const shape=svgNode("use",{
|
|
href:TOPO_MAP_ASSET+"#"+province.shape,
|
|
fill:mode==="rivers"?"#dce8df":province.color,
|
|
class:"topo-province"+(province.id===target?" target":"")+(province.id===picked?" picked":"")+(mode==="provinces"&&!selectable?" inactive":""),
|
|
"data-id":province.id,
|
|
});
|
|
const title=svgNode("title");
|
|
title.textContent=worldName(province);
|
|
shape.append(title);
|
|
if(onPick&&selectable)svgAction(shape,worldName(province),()=>onPick(province.id));
|
|
svg.append(shape);
|
|
if(showLabels&&mode==="provinces"&&selectable){
|
|
const label=svgNode("text",{x:province.x,y:province.y,class:"topo-label"});
|
|
label.textContent=worldName(province);
|
|
svg.append(label);
|
|
}
|
|
});
|
|
}
|
|
if(mode==="rivers")NL_RIVERS.forEach(river=>{
|
|
const selectable=active.has(river.id);
|
|
const group=svgNode("g",{
|
|
class:"topo-river"+(river.id===target?" target":"")+(river.id===picked?" picked":"")+(!selectable?" inactive":""),
|
|
"data-id":river.id,style:"--river-color:"+river.color,
|
|
});
|
|
group.append(
|
|
svgNode("path",{d:river.path,class:"topo-river-hit"}),
|
|
svgNode("path",{d:river.path,class:"topo-river-line"}),
|
|
);
|
|
const title=svgNode("title");
|
|
title.textContent=worldName(river);
|
|
group.append(title);
|
|
if(onPick&&selectable)svgAction(group,worldName(river),()=>onPick(river.id));
|
|
svg.append(group);
|
|
if(showLabels&&selectable){
|
|
const label=svgNode("text",{x:river.labelX,y:river.labelY,class:"topo-river-label"});
|
|
label.textContent=worldName(river);
|
|
svg.append(label);
|
|
}
|
|
});
|
|
if(mode==="capitals"||mode==="cities")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 makeWorldMap(pool,onPick,showLabels,picked,target){
|
|
const wrap=document.createElement("div");
|
|
wrap.className="topo-map-wrap world-map-wrap";
|
|
const svg=svgNode("svg",{
|
|
class:"topo-map world-map",viewBox:"0 0 720 360",
|
|
role:onPick?"group":"img","aria-label":T("topoWorldMapLabel"),
|
|
});
|
|
svg.append(svgNode("rect",{x:0,y:0,width:720,height:360,rx:24,class:"topo-water"}));
|
|
const active=new Set(pool.map(item=>item.id));
|
|
WORLD_CONTINENTS.forEach(continent=>{
|
|
const selectable=active.has(continent.id);
|
|
const shape=svgNode("use",{
|
|
href:TOPO_WORLD_ASSET+"#"+continent.shape,
|
|
fill:continent.color,
|
|
class:"world-continent"+(continent.id===target?" target":"")+(continent.id===picked?" picked":"")+(!selectable?" inactive":""),
|
|
"data-id":continent.id,
|
|
});
|
|
const title=svgNode("title");
|
|
title.textContent=worldName(continent);
|
|
shape.append(title);
|
|
if(onPick&&selectable)svgAction(shape,worldName(continent),()=>onPick(continent.id));
|
|
svg.append(shape);
|
|
if(showLabels&&selectable){
|
|
const label=svgNode("text",{x:continent.x,y:continent.y,class:"world-continent-label"});
|
|
label.textContent=worldName(continent);
|
|
svg.append(label);
|
|
}
|
|
});
|
|
const source=document.createElement("a");
|
|
source.className="topo-source";
|
|
source.href=TOPO_WORLD_SOURCE;
|
|
source.target="_blank";
|
|
source.rel="noopener noreferrer";
|
|
source.textContent=T("topoWorldMapSource");
|
|
wrap.append(svg);
|
|
if(showLabels){
|
|
const legend=document.createElement("div");
|
|
legend.className="topo-world-legend";
|
|
pool.forEach(continent=>{
|
|
const entry=document.createElement("span");
|
|
const swatch=document.createElement("i");
|
|
swatch.style.background=continent.color;
|
|
entry.append(swatch,worldName(continent));
|
|
legend.append(entry);
|
|
});
|
|
wrap.append(legend);
|
|
}
|
|
wrap.append(source);
|
|
return wrap;
|
|
}
|
|
|
|
function mountTopography(root,init){
|
|
const modeOptions=[
|
|
["provinces","topoProvinces"],["capitals","topoCapitals"],["cities","topoCities"],
|
|
["regions","topoRegions"],["rivers","topoRivers"],["continents","topoContinents"],
|
|
];
|
|
const S=Object.assign({
|
|
activity:"quiz",mode:"provinces",level:1,score:0,streak:0,
|
|
target:"",picked:"",solved:false,feedback:"",
|
|
},init||{});
|
|
S.level=Math.max(1,Math.min(3,+S.level||1));
|
|
if(!modeOptions.some(([id])=>id===S.mode))S.mode="provinces";
|
|
const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"]]);
|
|
const mode=worldSelect(S.mode,modeOptions);
|
|
const level=worldSelect(String(S.level),[["1","lvl1"],["2","lvl2"],["3","lvl3"]]);
|
|
const stage=document.createElement("div");
|
|
const levelSetting=field(T("worldLevel"),level);
|
|
const pool=()=>{
|
|
const items=S.mode==="provinces"?NL_PROVINCES
|
|
:S.mode==="capitals"?NL_CITIES.filter(item=>item.capital)
|
|
:S.mode==="cities"?NL_CITIES.filter(item=>!item.capital)
|
|
:S.mode==="regions"?NL_REGIONS
|
|
:S.mode==="rivers"?NL_RIVERS
|
|
:WORLD_CONTINENTS;
|
|
return items.filter(item=>item.tier<=S.level);
|
|
};
|
|
const current=()=>pool().find(item=>item.id===S.target)||pool()[0];
|
|
const newTask=()=>{
|
|
const items=pool(),previous=S.target;
|
|
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;
|
|
levelSetting.hidden=S.mode==="regions";
|
|
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";
|
|
const renderMap=(interactive,labels,pickedId,targetId)=>S.mode==="continents"
|
|
?makeWorldMap(pool(),interactive,labels,pickedId,targetId)
|
|
:makeTopoMap(S.mode,pool(),interactive,labels,pickedId,targetId);
|
|
if(S.activity==="learn"){
|
|
const intro=document.createElement("p");
|
|
intro.className="world-intro";
|
|
intro.textContent=T(S.mode==="regions"?"topoExploreRegions":S.mode==="rivers"?"topoExploreRivers":S.mode==="continents"?"topoExploreContinents":"topoExploreHint");
|
|
card.append(intro,renderMap(null,true,"",""));
|
|
}else{
|
|
const item=current(),question=document.createElement("h3");
|
|
const questionKey=S.mode==="provinces"?"topoFindProvince"
|
|
:S.mode==="capitals"?"topoFindCapital"
|
|
:S.mode==="cities"?"topoFindCity"
|
|
:S.mode==="regions"?"topoFindRegion"
|
|
:S.mode==="rivers"?"topoFindRiver"
|
|
:"topoFindContinent";
|
|
question.textContent=T(questionKey).replace("{name}",worldName(item));
|
|
card.append(question,renderMap(answer,S.solved,S.picked,S.solved?S.target:""));
|
|
const feedback=document.createElement("p");
|
|
feedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":"");
|
|
feedback.setAttribute("role","status");
|
|
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),
|
|
levelSetting,
|
|
],stage);
|
|
if(S.activity==="quiz")newTask();else draw();
|
|
return{getState:()=>({...S})};
|
|
}
|