teach/public/js/data.js
Ramon e7bb76b76d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s
feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta)
- Grootste openstaande roadmap-punt. Nieuwe tabel pupil_levels
  (db/019_pupil_levels.sql, zelfde class_id/pupil_id-vorm als
  assignments) legt per vakgebied (taal/rekenen/world) een niveaugroep
  vast (groep 1-8, gebaseerd op de leerjaren van de basisschool waaraan
  de leerdoelen gekoppeld zijn) + niveau (1-3, fijnmazige verfijning
  BINNEN die groep). Klas-standaard met individuele leerling-overrides,
  net zo laagdrempelig instelbaar als de standaard - het speciaal-
  onderwijs-geval waar dat vanaf het begin de norm is.
- Server: GET/PUT/DELETE /levels/class/:id en /levels/pupil/:id
  (src/api.js) hergebruiken exact het assignments-patroon (upsert via
  partial-unique-index, pupil-eerst-dan-klas-resolutie), plus
  GET /my/level voor de leerling-kant. Zelfde permissie
  (assignments.manage) en sameSchool/teacherOnly/pupilAccessible-
  guards als de bestaande toewijzingsroutes.
- Nieuwe admin-tab "Klassenmanagement": klas + vakgebied kiezen, een
  klas-standaard instellen, en per leerling de effectieve groep/niveau
  zien (overerft van de klas tenzij een eigen afwijking is ingesteld)
  met bewerk- en terug-naar-standaard-knoppen.
- ECHTE inhoudskoppeling, niet alleen een standaardwaarde: rekenen is
  het eerste vakgebied waarvan de SLO/TULE-leerlijn direct in
  getalgrenzen te vertalen is (groep 3 t/m 20, groep 4 t/m 100, groep 5
  t/m 1000, groep 6 t/m 10.000). Nieuwe SLO_MATH_RANGES/mathRangeFor()
  in data.js; math.js's gen() gebruikt dit voor de daadwerkelijke
  getallen zodra een leerling een groep heeft (S.yearGroup), met het
  bestaande niveau 1-3 als verfijning daarbinnen. Zonder groep blijft
  het oude vaste 10/20/100-gedrag exact bestaan.
- pupil.js geeft groep/niveau door aan taal/rekenen-widgets bij het
  mounten: niveau is een standaardwaarde (een al opgeslagen bord-niveau
  blijft leidend), groep wordt voor rekenen-widgets altijd meegegeven
  (er is geen "oud" groep-concept om te respecteren). Terzijde ook de
  stille .catch() op de nieuwe async renderPupilWidgets() opgelost.
- Taal/wereldoriëntatie krijgen deze ronde nog geen eigen
  inhoudskoppeling (vraagt een eigen contentbron per groep, nog niet
  uitgezocht) - eerlijk benoemd als vervolgstap, geen loze belofte.
- Tests: test/pupil-levels.test.js (10 tests) dekt de server-routes
  (mocked-pool-patroon) én - via dezelfde vm-techniek als elders in
  test/widgets.test.js voor MONEY_LEVELS - een ECHTE (niet regex-)test
  van mathRangeFor die bevestigt dat elke groep de juiste getalgrens
  oplevert en niveau nooit boven de groep-grens uitkomt. Twee bugs
  gevonden en gefixt tijdens het schrijven van deze tests: null-yearGroup
  werd per ongeluk als groep 1 behandeld i.p.v. "geen groep", en de
  testrol had geen klas-eigenaarschap waardoor 403 in plaats van 200
  terugkwam. Volledige testsuite: 128/128.
- Live geverifieerd in de sandbox: klas-standaard + leerling-override
  instellen in de nieuwe admin-tab, en drie scenario's in de
  leerling-omgeving die bevestigen dat de daadwerkelijk gegenereerde
  sommen binnen de juiste groep-grens blijven (geen klassenmanagement →
  ongewijzigd oud gedrag; groep zonder eigen bord-niveau → volledige
  groep-grens; groep mét een al opgeslagen bord-niveau → dat niveau
  blijft leidend, geschaald naar de juiste groep-grens).
- public/js/data.js bevatte al niet-gerelateerde, nog niet gecommitte
  wijzigingen van vóór deze sessie (extra taalthema's/woorden) - alleen
  de SLO_MATH_RANGES/mathRangeFor-hunk is hier meegenomen (git add -p),
  de rest blijft bewust ongemoeid staan, net als test/language-library.test.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
2026-07-19 15:31:26 +02:00

413 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* teach - woorddata: algemene kaart, thema's, gedeelde woordbronnen */
/* =========================================================
Letter game data
==========================================================*/
const DOTCODE = {
a:[1], b:[1,2], c:[1,4], d:[1,4,5], e:[1,5], f:[1,2,4], g:[1,2,4,5], h:[1,2,5],
i:[2,4], j:[2,4,5], k:[1,3], l:[1,2,3], m:[1,3,4], n:[1,3,4,5], o:[1,3,5],
p:[1,2,3,4], q:[1,2,3,4,5], r:[1,2,3,5], s:[2,3,4], t:[2,3,4,5], u:[1,3,6],
v:[1,2,3,6], w:[2,4,5,6], x:[1,3,4,6], y:[1,3,4,5,6], z:[1,3,5,6]
};
/* cijferblokken gebruikten hiervoor de braille a-j-patronen, maar die
gebruiken nooit posities 3/6 (de onderste rij) - op een kind-oog leken
alle tien blokken daardoor op elkaar. Een eigen codetabel die alle zes
stippen inzet, geeft elk cijfer een echt eigen vorm. */
const DIGIT_DOTCODE = {
"0":[], "1":[2], "2":[2,5], "3":[1,2,3], "4":[1,3,4,6],
"5":[1,2,3,4,5], "6":[1,2,3,4,5,6], "7":[1,2,4,5], "8":[1,2,3,4,6], "9":[1,4,6]
};
/* Klassenmanagement (v0.4.34-beta e.v.): rekenen is het eerste vakgebied waarvan
de leerlijn direct in getalgrenzen te vertalen is (SLO/TULE: groep 3
automatiseert optellen/aftrekken t/m 20, groep 4 t/m 100, groep 5 t/m 1000,
groep 6 t/m 10.000 met de tafels verder uitgebouwd, groep 7-8 bouwen daarop
voort). math.js gebruikt dit als de daadwerkelijke inhoud zodra een leerling
een groep heeft (zie mathRangeFor hieronder) - zonder groep blijft het oude,
vaste 10/20/100-gedrag exact bestaan. */
const SLO_MATH_RANGES = {
1:{addSub:10, timesTable:5},
2:{addSub:10, timesTable:5},
3:{addSub:20, timesTable:5},
4:{addSub:100, timesTable:10},
5:{addSub:1000, timesTable:10},
6:{addSub:10000,timesTable:12},
7:{addSub:10000,timesTable:12},
8:{addSub:10000,timesTable:12},
};
/* niveau (1-3) is de verfijning BINNEN een groep - een breukdeel van de
groep-grens, nooit onder de kleinste bestaande standaardwaarde (10) of
onder tafel 3. Levert bij ontbrekend yearGroup expres geen waarde (math.js
valt dan terug op zijn eigen oude vaste 10/20/100-logica). */
function mathRangeFor(yearGroup, level){
if(yearGroup == null) return null;
const g = SLO_MATH_RANGES[Math.max(1, Math.min(8, +yearGroup || 1))];
if(!g) return null;
const frac = level===1 ? .25 : level===2 ? .6 : 1;
return {
addSub: Math.max(10, Math.round(g.addSub*frac/5)*5),
timesTable: Math.max(3, Math.round(g.timesTable*frac)),
};
}
const VOWELS = "aeiou";
function dotGrid(letter){
const code = (Object.prototype.hasOwnProperty.call(DIGIT_DOTCODE, letter) ? DIGIT_DOTCODE[letter] : DOTCODE[letter]) || [];
const grid = document.createElement("div");
grid.className = "dots";
[1,4,2,5,3,6].forEach(p=>{
const d = document.createElement("i");
d.className = code.includes(p) ? "on" : "off";
grid.appendChild(d);
});
return grid;
}
/* De taalplaatjes hebben één centrale bron. Daardoor gebruiken de spelletjes,
de themakiezer en de afbeeldingenbibliotheek altijd dezelfde woorden en
verdwijnen oude plaatjes niet wanneer de woordenschat wordt uitgebreid. */
const LANGUAGE_THEME_DEFS = [
{id:"animals", icon:"🐾", nl:"Dieren", en:"Animals", colors:["#eaf7df","#cdeeb5"]},
{id:"food", icon:"🍎", nl:"Eten & drinken", en:"Food & drink", colors:["#fff0dc","#ffd9aa"]},
{id:"nature", icon:"🌿", nl:"Natuur & weer", en:"Nature & weather", colors:["#e4f7ee","#bfead4"]},
{id:"travel", icon:"🚲", nl:"Onderweg", en:"Getting around", colors:["#e6f3ff","#beddf8"]},
{id:"home", icon:"🎒", nl:"Thuis & school", en:"Home & school", colors:["#f1ecff","#d9ccff"]},
{id:"body", icon:"👕", nl:"Lichaam & kleding", en:"Body & clothes", colors:["#ffeaf1","#ffcbdc"]},
{id:"actions", icon:"🏃", nl:"Doen", en:"Actions", colors:["#fff8d9","#f6e8a4"]},
{id:"feelings", icon:"🙂", nl:"Gevoelens", en:"Feelings", colors:["#e9f5ff","#c8e4fa"]}
];
const LANGUAGE_CONCEPTS = [
["monkey","animals","aap","monkey","🐵"],
["cat","animals","kat","cat","🐈"],
["dog","animals","hond","dog","🐕"],
["fish","animals","vis","fish","🐟"],
["hen","animals","kip","hen","🐔"],
["mouse","animals","muis","mouse","🐭"],
["bear","animals","beer","bear","🐻"],
["duck","animals","eend","duck","🦆"],
["frog","animals","kikker","frog","🐸"],
["bee","animals","bij","bee","🐝"],
["cow","animals","koe","cow","🐄"],
["pig","animals","varken","pig","🐷"],
["owl","animals","uil","owl","🦉"],
["goat","animals","geit","goat","🐐"],
["hare","animals","haas","hare","🐇"],
["horse","animals","paard","horse","🐴"],
["lion","animals","leeuw","lion","🦁"],
["ant","animals","mier","ant","🐜"],
["egg","food","ei","egg","🥚"],
["apple","food","appel","apple","🍎"],
["pear","food","peer","pear","🍐"],
["cheese","food","kaas","cheese","🧀"],
["milk","food","melk","milk","🥛"],
["cake","food","taart","cake","🎂"],
["bread","food","brood","bread","🍞"],
["soup","food","soep","soup","🥣"],
["rice","food","rijst","rice","🍚"],
["corn","food","mais","corn","🌽"],
["grape","food","druif","grape","🍇"],
["strawberry","food","aardbei","strawberry","🍓"],
["candy","food","snoep","candy","🍬"],
["lemon","food","citroen","lemon","🍋"],
["pizza","food","pizza","pizza","🍕"],
["juice","food","sap","juice","🧃"],
["cookie","food","koek","cookie","🍪"],
["carrot","food","wortel","carrot","🥕"],
["sun","nature","zon","sun","☀️"],
["moon","nature","maan","moon","🌙"],
["star","nature","ster","star","⭐"],
["tree","nature","boom","tree","🌳"],
["rose","nature","roos","rose","🌹"],
["fire","nature","vuur","fire","🔥"],
["flower","nature","bloem","flower","🌷"],
["leaf","nature","blad","leaf","🍃"],
["cloud","nature","wolk","cloud","☁️"],
["rain","nature","regen","rain","🌧️"],
["snow","nature","sneeuw","snow","❄️"],
["water","nature","water","water","💧"],
["stone","nature","steen","stone","🪨"],
["sand","nature","zand","sand","🏖️"],
["sea","nature","zee","sea","🌊"],
["forest","nature","bos","forest","🌲"],
["grass","nature","gras","grass","🌱"],
["wind","nature","wind","wind","💨"],
["rainbow","nature","regenboog","rainbow","🌈"],
["car","travel","auto","car","🚗"],
["bike","travel","fiets","bike","🚲"],
["boat","travel","boot","boat","⛵"],
["train","travel","trein","train","🚂"],
["bus","travel","bus","bus","🚌"],
["taxi","travel","taxi","taxi","🚕"],
["truck","travel","vrachtauto","truck","🚚"],
["jet","travel","vliegtuig","plane","✈️"],
["tram","travel","tram","tram","🚋"],
["ship","travel","schip","ship","🚢"],
["rocket","travel","raket","rocket","🚀"],
["road","travel","weg","road","🛣️"],
["wheel","travel","wiel","wheel","🛞"],
["sail","travel","zeil","sail","⛵"],
["scooter","travel","step","scooter","🛴"],
["tractor","travel","tractor","tractor","🚜"],
["helicopter","travel","helikopter","helicopter","🚁"],
["house","home","huis","house","🏠"],
["book","home","boek","book","📕"],
["pen","home","pen","pen","🖊️"],
["pencil","home","potlood","pencil","✏️"],
["bag","home","tas","bag","🎒"],
["coat","home","jas","coat","🧥"],
["chair","home","stoel","chair","🪑"],
["bed","home","bed","bed","🛏️"],
["door","home","deur","door","🚪"],
["clock","home","klok","clock","⏰"],
["glasses","home","bril","glasses","👓"],
["ball","home","bal","ball","⚽"],
["doll","home","pop","doll","🪆"],
["block","home","blok","block","🧱"],
["key","home","sleutel","key","🔑"],
["lamp","home","lamp","lamp","💡"],
["cup","home","beker","cup","🥤"],
["brush","home","borstel","brush","🪥"],
["drum","home","trommel","drum","🥁"],
["gift","home","cadeau","gift","🎁"],
["paint","home","verf","paint","🎨"],
["scissors","home","schaar","scissors","✂️"],
["desk","home","bureau","desk","🗄️"],
["school","home","school","school","🏫"],
["hand","body","hand","hand","✋"],
["foot","body","voet","foot","🦶"],
["nose","body","neus","nose","👃"],
["eye","body","oog","eye","👁️"],
["ear","body","oor","ear","👂"],
["mouth","body","mond","mouth","👄"],
["hair","body","haar","hair","🧑‍🦱"],
["head","body","hoofd","head","🙂"],
["arm","body","arm","arm","💪"],
["leg","body","been","leg","🦵"],
["cap","body","pet","cap","🧢"],
["sock","body","sok","sock","🧦"],
["shoe","body","schoen","shoe","👟"],
["dress","body","jurk","dress","👗"],
["shirt","body","shirt","shirt","👕"],
["glove","body","want","glove","🧤"],
["pants","body","broek","trousers","👖"],
["skirt","body","rok","skirt","👗"],
["tooth","body","tand","tooth","🦷"],
["read","actions","lezen","read","📖"],
["eat","actions","eten","eat","🍽️"],
["drink","actions","drinken","drink","🥤"],
["sleep","actions","slapen","sleep","😴"],
["walk","actions","lopen","walk","🚶"],
["swim","actions","zwemmen","swim","🏊"],
["jump","actions","springen","jump","🤸"],
["laugh","actions","lachen","laugh","😄"],
["cry","actions","huilen","cry","😢"],
["sing","actions","zingen","sing","🎤"],
["dance","actions","dansen","dance","💃"],
["draw","actions","tekenen","draw","🖍️"],
["write","actions","schrijven","write","✍️"],
["play","actions","spelen","play","🧸"],
["sit","actions","zitten","sit","🧎"],
["run","actions","rennen","run","🏃"],
["climb","actions","klimmen","climb","🧗"],
["wave","actions","zwaaien","wave","👋"],
["look","actions","kijken","look","👀"],
["think","actions","denken","think","🤔"],
["happy","feelings","blij","happy","😀"],
["sad","feelings","verdrietig","sad","😢"],
["angry","feelings","boos","angry","😠"],
["scared","feelings","bang","scared","😨"],
["tired","feelings","moe","tired","🥱"],
["sick","feelings","ziek","sick","🤒"],
["proud","feelings","trots","proud","😌"],
["calm","feelings","rustig","calm","🙂"],
["surprised","feelings","verrast","surprised","😮"],
["excited","feelings","enthousiast","excited","🤩"]
].map(([id,theme,nl,en,pic])=>({id,theme,nl,en,pic}));
/* De vertrouwde startkaart blijft exact dezelfde 30 woorden houden. */
const START_WORD_KEYS = {
nl:["monkey","fish","sun","cat","ball","egg","tree","house","moon","boat","hen","mouse","book","star","rose","fire","bike","cake","coat","glasses","clock","pear","cheese","duck","hand","foot","nose","eye","ear","mouth"],
en:["cat","dog","sun","egg","ball","fish","tree","moon","boat","star","book","cake","house","mouse","apple","train","fire","rose","bear","duck","frog","bee","cow","pig","hand","foot","nose","eye","ear","milk"]
};
const WORDS = Object.fromEntries(["nl","en"].map(lang=>[
lang, START_WORD_KEYS[lang].map(id=>{
const concept=LANGUAGE_CONCEPTS.find(item=>item.id===id);
return [concept[lang],concept.pic];
})
]));
function languageThemeList(lang=LANG){
return LANGUAGE_THEME_DEFS.map(theme=>({
...theme,
name:theme[lang],
words:LANGUAGE_CONCEPTS.filter(item=>item.theme===theme.id).map(item=>[item[lang],item.pic])
}));
}
function languageEmojiDataUrl(emoji,colors=["#f4f8ff","#dce9fa"]){
const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320"><defs><linearGradient id="g" x2="1" y2="1"><stop stop-color="${colors[0]}"/><stop offset="1" stop-color="${colors[1]}"/></linearGradient></defs><rect width="320" height="320" rx="48" fill="url(#g)"/><circle cx="160" cy="160" r="112" fill="#fff" opacity=".92"/><text x="160" y="177" text-anchor="middle" dominant-baseline="middle" font-family="Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif" font-size="148">${emoji}</text></svg>`;
return "data:image/svg+xml;charset=utf-8,"+encodeURIComponent(svg);
}
function languagePictureCatalog(lang=LANG){
const builtinThemes=languageThemeList(lang).map((theme,index)=>({
id:-200-index,nameNl:theme.nl,nameEn:theme.en,scope:"global",filterOnly:true,
languageBuiltin:true,wordPicture:true,icon:theme.icon
}));
const themeIds=new Map(LANGUAGE_THEME_DEFS.map((theme,index)=>[theme.id,-200-index]));
const images=LANGUAGE_CONCEPTS.map((item,index)=>{
const theme=LANGUAGE_THEME_DEFS.find(def=>def.id===item.theme);
return {
id:-1000-index,name:item[lang],folder:`Taal/Woordenschat/${theme[lang]}`,
scope:"global",themes:[themeIds.get(item.theme)],tags:[item.nl,item.en,theme.nl,theme.en],
src:languageEmojiDataUrl(item.pic,theme.colors),value:item.pic,canManage:false,canDelete:false,
wordPicture:true,builtin:true
};
});
const ownThemes=[];
const customImages=[];
let customId=-5000;
const addOwn=(words,name,folder,themeId)=>{
(words||[]).forEach(([word,pic])=>{
if(!word||!pic)return;
customImages.push({
id:customId--,name:word,folder,scope:"personal",themes:[themeId],tags:[word,name],
src:isImg(pic)?pic:languageEmojiDataUrl(pic),value:pic,canManage:false,canDelete:false,
wordPicture:true,builtin:false
});
});
};
const generalThemeId=-400;
ownThemes.push({id:generalThemeId,nameNl:"Mijn woordkaarten",nameEn:"My word cards",scope:"personal",filterOnly:true,wordPicture:true,icon:"⭐"});
addOwn(GENERAL_EXTRA[lang],lang==="nl"?"Mijn woordkaarten":"My word cards","Taal/Mijn woordkaarten",generalThemeId);
THEMES[lang].forEach((theme,index)=>{
const id=-401-index;
ownThemes.push({id,nameNl:theme.name,nameEn:theme.name,scope:"personal",filterOnly:true,wordPicture:true,icon:"📁"});
addOwn(theme.words,theme.name,["Taal/Mijn themas",theme.folder,theme.name].filter(Boolean).join("/"),id);
});
return {themes:builtinThemes.concat(ownThemes),images:images.concat(customImages)};
}
/* teacher's own additions to the general card (persisted per user) */
let GENERAL_EXTRA = {nl:[], en:[]};
function generalList(){ return WORDS[LANG].concat(GENERAL_EXTRA[LANG]); }
const shuffle = a => { a=a.slice(); for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; };
/* custom themes: folders of own words.
{nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */
let THEMES = {nl:[], en:[]};
/* ankerbibliotheek: los van borden bewaarde ankerdiagrammen, herbruikbaar op
elk bord. [{id, name, folder?, cells, pics}] - cells/pics in dezelfde vorm
als de widget-staat van anchor.js; folder is een pad ("A/B") zoals bij de
thema's en borden in de mappenverkenner. */
let ANCHOR_LIB = [];
function themesFromData(data){
if(!data) return {nl:[],en:[]};
if(data.themes) return data.themes;
/* migrate old flat word lists into one theme */
const t = {nl:[],en:[]};
if(data.words) ["nl","en"].forEach(l=>{
if(Array.isArray(data.words[l]) && data.words[l].length)
t[l] = [{name: l==="nl" ? "Mijn woorden" : "My words", words: data.words[l]}];
});
return t;
}
const isImg = p => typeof p==="string" && /^(?:data:|https?:|blob:|\/)/.test(p);
function setPic(el, pic){
if(isImg(pic)){ el.innerHTML = ""; const im = document.createElement("img"); im.src = pic; im.alt=""; el.appendChild(im); }
else el.textContent = pic;
}
/* ---- ARASAAC pictogram lookup (shared by the word editor + anchor grid) ---- */
async function arasaacSearch(term, lang){
const resp = await fetch(`https://api.arasaac.org/api/pictograms/${lang}/search/${encodeURIComponent(term)}`);
if(!resp.ok) throw new Error("http "+resp.status);
return resp.json();
}
const arasaacUrl = id => `https://static.arasaac.org/pictograms/${id}/${id}_300.png`;
/* ---- shared word source (general card + themes + anchor grids) ---- */
/* anchor-word widgets register their words here so every language widget
can use them as a theme automatically */
let ANCHORS = []; /* [{el, name, words:[[w,pic],...]}] */
function anchorSources(){
ANCHORS = ANCHORS.filter(a=>a.el && document.contains(a.el));
return ANCHORS.filter(a=>a.words.length);
}
function normWordSource(card){
if(typeof card==="string"){
if(card.startsWith("b:")){
if(languageThemeList().some(theme=>theme.id===card.slice(2))) return card;
}else if(card.startsWith("t")){
const th = THEMES[LANG][+card.slice(1)];
if(th && th.words.length) return card;
}else if(card.startsWith("a")){
if(anchorSources()[+card.slice(1)]) return card;
}
}
return "g";
}
function wordSourceList(card){
card = normWordSource(card);
if(card.startsWith("b:")) return languageThemeList().find(theme=>theme.id===card.slice(2)).words;
if(card.startsWith("t")) return THEMES[LANG][+card.slice(1)].words;
if(card.startsWith("a")) return anchorSources()[+card.slice(1)].words;
return generalList();
}
function fillWordSourceSel(sel, card){
sel.innerHTML = "";
const og = document.createElement("option");
og.value = "g"; og.textContent = "⭐ "+T("cardStart");
sel.appendChild(og);
const builtinGroup = document.createElement("optgroup");
builtinGroup.label = "📚 "+T("wordThemes");
languageThemeList().forEach(theme=>{
const option=document.createElement("option");
option.value="b:"+theme.id;
option.textContent=`${theme.icon} ${theme.name} (${theme.words.length})`;
builtinGroup.appendChild(option);
});
sel.appendChild(builtinGroup);
/* thema's zonder map bovenaan, daarna per map gegroepeerd (optgroup) -
zelfde indeling als de theme-dropdown in de woorden-editor */
const groups = new Map();
THEMES[LANG].forEach((th,i)=>{
if(!th.words.length) return;
const f = th.folder || "";
if(!groups.has(f)) groups.set(f, []);
groups.get(f).push([th, i]);
});
const addOpt = (parent, th, i)=>{
const o = document.createElement("option");
o.value = "t"+i; o.textContent = "★ "+th.name;
parent.appendChild(o);
};
(groups.get("") || []).forEach(([th,i])=>addOpt(sel, th, i));
[...groups.keys()].filter(f=>f).sort().forEach(f=>{
const grp = document.createElement("optgroup");
grp.label = "📁 " + f;
groups.get(f).forEach(([th,i])=>addOpt(grp, th, i));
sel.appendChild(grp);
});
anchorSources().forEach((a,i)=>{
const o = document.createElement("option");
o.value = "a"+i; o.textContent = "⚓ "+a.name;
sel.appendChild(o);
});
sel.value = normWordSource(card);
if(sel.selectedIndex < 0) sel.selectedIndex = 0;
}
/* emoji palette (grouped) */
const splitEmoji = s => {
const out = [];
for(const ch of s){
if(out.length && (ch==="" || ch==="")) out[out.length-1]+=ch;
else if(out.length && out[out.length-1].endsWith("")) out[out.length-1]+=ch;
else out.push(ch);
}
return out;
};
const EMOJI_CATS = [
splitEmoji("🐵🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐔🐧🐦🦆🦉🐴🦄🐝🐛🦋🐌🐞🐠🐟🐬🐳🐢🐍🦎🐘🦒"),
splitEmoji("🍎🍐🍊🍋🍌🍉🍇🍓🍒🍑🥝🍅🥕🌽🥔🧀🥚🍞🥐🥞🍕🍟🍦🍪🎂🍫🍬🥛🧃🍯"),
splitEmoji("🌳🌲🌴🌵🌷🌹🌻🌸💐🍄⭐🌙☀️⛅🌈❄️🔥💧🌊🪨"),
splitEmoji("🚗🚕🚌🚑🚒🚜🚲🛴🏍️🚂✈️🚁⛵🚀🛶🚢"),
splitEmoji("⚽🏀🎈🎁🧸📕✏️🖍️✂️🎨🥁🎸👓⌚⏰💡🔑🧤🧢👟👗🧥🛏️🪑🚪🏠"),
splitEmoji("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴")
];