teach/public/js/widgets/mind.js
bes-r d1d3265c6c
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
v0.1.02: opsplitsing in css/, js/core|data|board|app en js/widgets/* (geen functionele wijziging)
2026-07-09 21:56:49 +02:00

252 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

/* widget: Mindmap */
/* =========================================================
Mindmap widget — spider, tree or word web
==========================================================*/
function mountMind(root, initState){
const S = { layout:"spider", nodes:[{id:1, x:0.5, y:0.45, t:"", parent:null}], nextId:2 };
if(initState){
S.layout = initState.layout || "spider";
if(Array.isArray(initState.nodes) && initState.nodes.length) S.nodes = initState.nodes;
S.nextId = initState.nextId || (Math.max(...S.nodes.map(n=>n.id))+1);
}
const PALETTE = ["#3b7dd8","#3fae6a","#e0554d","#9b59b6","#f28c38"];
root.innerHTML = `
<div class="mm spider">
<div class="tseg mm-seg">
<button data-m="spider"></button><button data-m="tree"></button><button data-m="web"></button>
</div>
<div class="mm-hint"></div>
<div class="mm-stage"><svg></svg></div>
</div>`;
const mmEl = root.querySelector(".mm");
/* toolbar row wrapper */
const topRow = document.createElement("div");
topRow.className = "mm-top";
mmEl.insertBefore(topRow, mmEl.firstChild);
const mmGrip = document.createElement("span");
mmGrip.className = "mm-grip"; mmGrip.textContent = "⠿";
topRow.appendChild(mmGrip);
topRow.appendChild(root.querySelector(".mm-seg"));
topRow.appendChild(root.querySelector(".mm-hint"));
mmGrip.addEventListener("pointerdown", e=>{
e.preventDefault(); e.stopPropagation();
const mr = mmEl.getBoundingClientRect();
const sc = mr.width / mmEl.offsetWidth; /* effective zoom on this widget */
const ox = e.clientX/sc - topRow.offsetLeft, oy = e.clientY/sc - topRow.offsetTop;
const mv = ev=>{
topRow.style.left = Math.max(0, Math.min(mmEl.offsetWidth - topRow.offsetWidth, ev.clientX/sc - ox))+"px";
topRow.style.top = Math.max(0, Math.min(mmEl.offsetHeight - topRow.offsetHeight, ev.clientY/sc - oy))+"px";
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
const stage = root.querySelector(".mm-stage"),
svg = stage.querySelector("svg");
function nodeById(id){ return S.nodes.find(n=>n.id===id); }
function depth(n){ let d=0, c=n; while(c && c.parent!=null){ c = nodeById(c.parent); d++; } return d; }
function drawLines(){
const w = stage.clientWidth, h = stage.clientHeight;
let paths = "";
S.nodes.forEach(n=>{
if(n.parent==null) return;
const p = nodeById(n.parent);
if(!p) return;
const x1 = p.x*w, y1 = p.y*h, x2 = n.x*w, y2 = n.y*h;
const col = n.lc || PALETTE[(depth(n)-1) % PALETTE.length];
const dash = n.ls==="dash" ? ' stroke-dasharray="11 8"' : n.ls==="dot" ? ' stroke-dasharray="2.5 7"' : "";
if(S.layout==="spider"){
const mx = (x1+x2)/2 + (y2-y1)*0.18, my = (y1+y2)/2 - (x2-x1)*0.18;
paths += `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
}else if(S.layout==="tree"){
const my = (y1+y2)/2;
paths += `<path d="M${x1},${y1} L${x1},${my} L${x2},${my} L${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
}else{
paths += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3"${dash}/>`;
}
});
svg.innerHTML = paths;
}
function applyNodeStyle(n, d){
if(n.parent!=null) d.style.borderColor = n.c || PALETTE[(depth(n)-1) % PALETTE.length];
else if(n.c) d.style.borderColor = n.c;
d.style.fontSize = (n.fs || (n.parent==null ? 17 : 15))+"px";
}
function buildNode(n){
const d = document.createElement("div");
d.className = "mm-node" + (n.parent==null ? " root" : "");
applyNodeStyle(n, d);
d.dataset.id = n.id;
const span = document.createElement("span");
span.contentEditable = "true";
span.spellcheck = false;
span.textContent = n.t || (n.parent==null ? T("mmRoot") : "");
span.addEventListener("input", ()=>{ n.t = span.textContent; });
span.addEventListener("keydown", e=>e.stopPropagation());
span.addEventListener("pointerdown", e=>e.stopPropagation());
d.appendChild(span);
const add = document.createElement("button");
add.className = "mm-add"; add.textContent = "";
add.addEventListener("pointerdown", e=>e.stopPropagation());
add.addEventListener("click", ()=>{
const a = Math.random()*Math.PI*2;
S.nodes.push({
id: S.nextId++, parent: n.id,
x: Math.min(.95, Math.max(.05, n.x + Math.cos(a)*0.17)),
y: Math.min(.92, Math.max(.06, n.y + Math.sin(a)*0.2)),
t: ""
});
render();
});
d.appendChild(add);
const cog = document.createElement("button");
cog.className = "mm-cog"; cog.textContent = "🎨";
cog.addEventListener("pointerdown", e=>e.stopPropagation());
cog.addEventListener("click", ()=>openNodePop(n, d));
d.appendChild(cog);
if(n.parent!=null){
const del = document.createElement("button");
del.className = "mm-del"; del.textContent = "✕";
del.addEventListener("pointerdown", e=>e.stopPropagation());
del.addEventListener("click", ()=>{
const doomed = new Set([n.id]);
let grew = true;
while(grew){
grew = false;
S.nodes.forEach(m=>{
if(m.parent!=null && doomed.has(m.parent) && !doomed.has(m.id)){ doomed.add(m.id); grew = true; }
});
}
S.nodes = S.nodes.filter(m=>!doomed.has(m.id));
render();
});
d.appendChild(del);
}
/* drag */
d.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
const r = stage.getBoundingClientRect();
const mv = ev=>{
n.x = Math.min(.98, Math.max(.02, (ev.clientX-r.left)/r.width));
n.y = Math.min(.96, Math.max(.04, (ev.clientY-r.top)/r.height));
d.style.left = (n.x*100)+"%";
d.style.top = (n.y*100)+"%";
drawLines();
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv);
document.addEventListener("pointerup",up);
});
d.style.left = (n.x*100)+"%";
d.style.top = (n.y*100)+"%";
return d;
}
/* per-node settings popover: colour, font size, line colour & style */
function closeNodePop(){ stage.querySelectorAll(".mm-pop").forEach(x=>x.remove()); }
function openNodePop(n, nodeEl){
closeNodePop();
const pop = document.createElement("div");
pop.className = "mm-pop";
const w = stage.clientWidth, h = stage.clientHeight;
pop.style.left = Math.min(n.x*w + 30, w-190)+"px";
pop.style.top = Math.min(n.y*h + 24, h-150)+"px";
pop.addEventListener("pointerdown", e=>e.stopPropagation());
const restyle = ()=>{ applyNodeStyle(n, nodeEl); drawLines(); refreshPop(); };
const row = (lbl, title)=>{
const r = document.createElement("div");
r.className = "mp-row"; r.title = title;
const l = document.createElement("span");
l.className = "mp-lbl"; l.textContent = lbl;
r.appendChild(l);
pop.appendChild(r);
return r;
};
/* node colour */
const r1 = row("⬤", T("nodeColor"));
PALETTE.concat(["#26344a"]).forEach(c=>{
const b = document.createElement("button");
b.className = "mp-sw"; b.dataset.v = c; b.style.background = c;
b.addEventListener("click", ()=>{ n.c = (n.c===c ? null : c); restyle(); });
r1.appendChild(b);
});
const xBtn = document.createElement("button");
xBtn.className = "mp-x"; xBtn.textContent = "✕";
xBtn.addEventListener("click", closeNodePop);
r1.appendChild(xBtn);
/* font size */
const r2 = row("A", T("nodeFont"));
[["A",13],["A",16],["A",20]].forEach(([txt,fs],i)=>{
const b = document.createElement("button");
b.className = "mp-fs"; b.dataset.v = fs;
b.textContent = txt; b.style.fontSize = (11+i*3)+"px";
b.addEventListener("click", ()=>{ n.fs = fs; restyle(); });
r2.appendChild(b);
});
if(n.parent!=null){
/* line colour */
const r3 = row("", T("lineColor"));
PALETTE.concat(["#26344a"]).forEach(c=>{
const b = document.createElement("button");
b.className = "mp-sw"; b.dataset.v = "l"+c; b.style.background = c;
b.addEventListener("click", ()=>{ n.lc = (n.lc===c ? null : c); restyle(); });
r3.appendChild(b);
});
/* line style */
const r4 = row("┄", T("lineStyle"));
[["solid",""],["dash","dash"],["dot","dot"]].forEach(([id,val])=>{
const b = document.createElement("button");
b.className = "mp-ls"; b.dataset.ls = id; b.dataset.v = "s"+id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ n.ls = val || null; restyle(); });
r4.appendChild(b);
});
}
function refreshPop(){
pop.querySelectorAll(".mp-sw").forEach(b=>{
const v = b.dataset.v;
b.classList.toggle("on", v.startsWith("l") ? n.lc===v.slice(1) : n.c===v);
});
pop.querySelectorAll(".mp-fs").forEach(b=>
b.classList.toggle("on", +b.dataset.v === (n.fs || (n.parent==null?17:15))));
pop.querySelectorAll(".mp-ls").forEach(b=>
b.classList.toggle("on", (n.ls||"") === (b.dataset.ls==="solid" ? "" : b.dataset.ls)));
}
refreshPop();
stage.appendChild(pop);
}
document.addEventListener("pointerdown", e=>{
if(!stage.querySelector(".mm-pop")) return;
if(!e.target.closest(".mm-pop") && !e.target.closest(".mm-cog")) closeNodePop();
});
function render(){
mmEl.className = "mm "+S.layout;
root.querySelectorAll(".mm-seg button").forEach(b=>{
b.textContent = T(b.dataset.m==="spider" ? "mmSpider" : b.dataset.m==="tree" ? "mmTree" : "mmWeb");
b.classList.toggle("on", b.dataset.m===S.layout);
});
root.querySelector(".mm-hint").textContent = T("mmHint");
closeNodePop();
stage.querySelectorAll(".mm-node").forEach(x=>x.remove());
S.nodes.forEach(n=>stage.appendChild(buildNode(n)));
drawLines();
}
root.querySelectorAll(".mm-seg button").forEach(b=>{
b.addEventListener("click", ()=>{ S.layout = b.dataset.m; render(); });
});
new ResizeObserver(drawLines).observe(stage);
document.addEventListener("langchange", render);
render();
return {
getState: ()=>({ layout:S.layout, nodes:S.nodes, nextId:S.nextId }),
setState: st=>{ if(st && st.nodes){ S.layout=st.layout||"spider"; S.nodes=st.nodes; S.nextId=st.nextId||S.nextId; render(); } }
};
}