/* 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 = `
`; 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(); persistWords(); }; 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 }) }; }