/* 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:{}, themeId:null }; 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 -> resolved picture (undefined/null/URL) */ /* migrate: older boards stored the literal anchor emoji as a "picture" - treat that the same as "no match found" now that it's no longer shown as a picture */ Object.keys(S.pics).forEach(w=>{ if(S.pics[w] === "⚓") S.pics[w] = null; }); S.themeId = initState.themeId || null; } /* S.pics[word]: undefined = not tried yet, null = tried, no ARASAAC match (shown empty, no fake anchor icon), string = resolved picture URL */ const fetching = new Set(); 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) : null; }).catch(()=>{ S.pics[word] = null; }).finally(()=>{ fetching.delete(word); syncSource(); render(); persistWords(); }); } 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); /* mirror words that already have a real picture into a linked theme, so the existing woordkaart-editor (letters.js) can manage/fix their pictures too - the anchor grid stays the source of truth for which words exist */ function syncTheme(){ if(!S.themeId) S.themeId = "an-"+Math.random().toString(36).slice(2,10); const withPic = reg.words.filter(([,p]) => typeof p === "string"); const list = THEMES[LANG]; const idx = list.findIndex(t => t.id === S.themeId); if(!withPic.length){ if(idx >= 0) list[idx].words = []; return; } const name = "⚓ " + reg.name; if(idx < 0) list.push({ id: S.themeId, name, words: withPic.slice() }); else{ list[idx].name = name; list[idx].words = withPic.slice(); } } 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,24}$/.test(w) && !seen.has(w)){ seen.add(w); if(S.pics[w] === undefined) fetchPic(w); reg.words.push([w, S.pics[w] ?? null]); } }); syncTheme(); 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 normWord = word.trim().toLowerCase(); const wPic = S.pics[normWord]; const pic = document.createElement("div"); pic.className = "an-pic"; if(wPic){ setPic(pic, wPic); }else{ /* no automatic match (or not tried yet) - stays empty, but doubles as a shortcut to manually pick a picture instead of showing a fake anchor icon */ pic.classList.add("an-pic-empty"); pic.title = T("pickPic"); pic.addEventListener("pointerdown", ev=>ev.stopPropagation()); pic.addEventListener("click", ev=>{ ev.stopPropagation(); openPicPicker(normWord, chosen=>{ S.pics[normWord] = chosen; syncSource(); render(); persistWords(); }); }); } c.appendChild(pic); 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, themeId:S.themeId }) }; }