teach/public/js/widgets/anchor.js
Ramon d05b12bb86
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
v0.2.06-beta: automatische plaatjes per woord in Ankerwoorden
- Ankerwoorden-widget: elk woord krijgt nu automatisch een passend plaatje via de ARASAAC-pictogrammendienst, in plaats van steeds hetzelfde ankersymbool
- Plaatjes worden per woord gecachet en bewaard in de bordstatus, zodat ze niet steeds opnieuw opgehaald worden
- Bij geen resultaat of een netwerkfout valt de widget terug op het ankersymbool
- ARASAAC-opzoekfunctie hergebruikt vanuit de bestaande woordkaart-editor (geen dubbele code meer)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 08:55:43 +02:00

176 lines
6.7 KiB
JavaScript

/* widget: Ankerwoorden */
/* =========================================================
Anchor words — unlimited word grid around a theme
==========================================================*/
function mountAnchor(root, initState){
const CW = 128, CH = 56, G = 10;
/* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */
const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"];
const S = { cells:{}, pan:{x:0, y:0}, pics:{} };
if(initState){
const src = initState.cells || {};
Object.keys(src).forEach(k=>{
const v = src[k];
/* migrate: old format stored plain strings */
S.cells[k] = (typeof v==="string") ? {t:v, c:null} : {t:v.t||"", c:v.c||null};
});
S.pan = initState.pan || {x:0, y:0};
S.pics = initState.pics || {}; /* word -> auto-fetched pictogram (or fallback emoji) */
}
const FALLBACK_PIC = "⚓";
const fetching = new Set();
/* auto-fetch a matching picture per word instead of using the anchor emoji for everything */
function fetchPic(word){
if(S.pics[word] !== undefined || fetching.has(word)) return;
fetching.add(word);
arasaacSearch(word, LANG).then(arr=>{
S.pics[word] = (arr && arr.length) ? arasaacUrl(arr[0]._id) : FALLBACK_PIC;
}).catch(()=>{
S.pics[word] = FALLBACK_PIC;
}).finally(()=>{
fetching.delete(word);
syncSource();
});
}
root.innerHTML = `
<div class="an">
<div class="an-hint"></div>
<div class="an-stage"><div class="an-layer"></div></div>
</div>`;
const stage = root.querySelector(".an-stage"),
layer = root.querySelector(".an-layer"),
hint = root.querySelector(".an-hint");
const key = (x,y)=>x+","+y;
/* register this grid as a word source for the language widgets */
const reg = { el: root, name: T("wg_anchor"), words: [] };
ANCHORS.push(reg);
function syncSource(quiet){
const center = S.cells["0,0"];
reg.name = (center && center.t) ? center.t : T("wg_anchor");
const seen = new Set();
reg.words = [];
Object.keys(S.cells).forEach(k=>{
const w = (S.cells[k].t||"").trim().toLowerCase();
if(/^[a-zà-ÿ]{2,12}$/.test(w) && !seen.has(w)){
seen.add(w);
if(S.pics[w] === undefined) fetchPic(w);
reg.words.push([w, S.pics[w] || FALLBACK_PIC]);
}
});
if(!quiet) document.dispatchEvent(new CustomEvent("wordschange"));
}
function applyPan(){ layer.style.transform = `translate(${-S.pan.x}px,${-S.pan.y}px)`; }
function render(){
layer.innerHTML = "";
const w = stage.offsetWidth, h = stage.offsetHeight;
if(w < 2) return;
const cx = w/2, cy = h/2;
const x0 = Math.floor((S.pan.x - cx - CW)/(CW+G)), x1 = Math.ceil((S.pan.x + w - cx + CW)/(CW+G));
const y0 = Math.floor((S.pan.y - cy - CH)/(CH+G)), y1 = Math.ceil((S.pan.y + h - cy + CH)/(CH+G));
for(let gx=x0; gx<=x1; gx++){
for(let gy=y0; gy<=y1; gy++){
const c = document.createElement("div");
const cell = S.cells[key(gx,gy)];
const word = cell && cell.t;
const isRoot = gx===0 && gy===0;
c.className = "an-cell" + (word ? " filled" : "") + (isRoot ? " root" : "");
c.style.left = (cx + gx*(CW+G) - CW/2)+"px";
c.style.top = (cy + gy*(CH+G) - CH/2)+"px";
if(word){
const span = document.createElement("span");
span.className = "an-word";
span.textContent = word;
c.appendChild(span);
/* only the theme word is styled by default; other words are plain
until the teacher gives them a colour to show a connection */
if(!isRoot){
if(cell.c){
c.style.borderColor = cell.c;
c.style.background = cell.c + "1c";
}
const dot = document.createElement("button");
dot.className = "an-dot";
dot.style.background = cell.c || "#cdd7e4";
dot.title = T("anchorColor");
dot.addEventListener("pointerdown", ev=>ev.stopPropagation());
dot.addEventListener("click", ev=>{
ev.stopPropagation();
cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length];
render();
});
c.appendChild(dot);
}
}
c.dataset.gx = gx; c.dataset.gy = gy;
layer.appendChild(c);
}
}
applyPan();
hint.textContent = T("anchorHint");
}
/* pan by dragging, click (without moving) to type */
let pd = null;
stage.addEventListener("pointerdown", e=>{
if(e.target.tagName==="INPUT") return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
stage.setPointerCapture(e.pointerId);
const sc = stage.getBoundingClientRect().width / stage.offsetWidth;
pd = { x:e.clientX/sc, y:e.clientY/sc, px:S.pan.x, py:S.pan.y,
moved:false, target:e.target.closest(".an-cell"), sc };
});
stage.addEventListener("pointermove", e=>{
if(!pd) return;
const dx = e.clientX/pd.sc - pd.x, dy = e.clientY/pd.sc - pd.y;
if(Math.abs(dx) > 6 || Math.abs(dy) > 6) pd.moved = true;
if(pd.moved){ S.pan.x = pd.px - dx; S.pan.y = pd.py - dy; applyPan(); }
});
stage.addEventListener("pointerup", ()=>{
if(!pd) return;
const {moved, target} = pd;
pd = null;
if(moved){ render(); return; }
if(target) editCell(target);
});
stage.addEventListener("pointercancel", ()=>{
const moved = pd && pd.moved;
pd = null;
if(moved) render();
});
function editCell(cell){
if(cell.querySelector("input")) return;
const gx = +cell.dataset.gx, gy = +cell.dataset.gy, k = key(gx,gy);
const inp = document.createElement("input");
inp.maxLength = 24;
inp.value = S.cells[k] ? S.cells[k].t : "";
if(gx===0 && gy===0) inp.placeholder = T("anchorPh");
cell.textContent = "";
cell.appendChild(inp);
setTimeout(()=>{ inp.focus(); inp.select(); }, 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v) S.cells[k] = { t:v, c:(S.cells[k] && S.cells[k].c) || null };
else delete S.cells[k];
syncSource();
render();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; render(); }
});
inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
}
new ResizeObserver(render).observe(stage);
document.addEventListener("langchange", render);
syncSource(true);
render();
if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange"));
return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics }) };
}