teach/public/js/widgets/coloring.js
Ramon 66d6cf4d46
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
feat: bouw kleurplatenatlas opnieuw op (v0.4.74-beta)
- Teken alle 28 scènes vanaf een leeg canvas opnieuw met samenhangende silhouetten en detailniveaus per leerjaar.
- Geef dieren soortspecifieke anatomie, natuurlijke verhoudingen en vloeiende realistische contouren.
- Borg gesloten vulvakken, correcte laagvolgorde en blijvend zichtbare contour- en binnenlijnen.
- Verwijder de oude reparatielaag en voeg anatomische en functionele regressietests toe.
2026-07-29 16:59:27 +02:00

250 lines
20 KiB
JavaScript

/* teach - thematische kleurplaten met kwast en vlakvulling */
"use strict";
const COLORING_THEME_KEYS=Object.freeze({
all:"colorThemeAll",animals:"colorThemeAnimals",seasons:"colorThemeSeasons",
nature:"colorThemeNature",science:"colorThemeScience",culture:"colorThemeCulture"
});
const COLORING_COLORS=Object.freeze([
"#26344a","#e0554d","#f28c3c","#f7c948","#3fae6a","#2fb7a8",
"#3b7dd8","#7557c8","#d85aa5","#8b5a3c","#f2b38f","#ffffff"
]);
function coloringPart(tag,id,attrs="",level=1){
const backdrop=tag==="rect"&&attrs==='x="4" y="4" width="792" height="592" rx="28"';
return `<${tag} class="coloring-region" data-fillable="true" data-part="${id}"${backdrop?' data-role="backdrop"':""}${level>1?` data-level="${level}"`:""} ${attrs}/>`;
}
function coloringLine(tag,attrs="",level=1){
return `<${tag} class="coloring-detail"${level>1?` data-level="${level}"`:""} ${attrs}/>`;
}
function coloringScene(content){
return `<svg class="coloring-sheet" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<g class="coloring-svg-lines" fill="#fff" fill-rule="evenodd" clip-rule="evenodd" stroke="#26344a" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="2">
${content}
</g>
</svg>`;
}
function coloringFigure(id,content,attrs=""){
return `<g class="coloring-figure" data-figure="${id}"${attrs?` ${attrs}`:""}>${content}</g>`;
}
function coloringDot(cx,cy,r=5,level=1){
return coloringLine("circle",`cx="${cx}" cy="${cy}" r="${r}" fill="#26344a" stroke="none"`,level);
}
function coloringCloud(id,x,y,scale=1,level=1){
return coloringPart("path",id,`d="M 0 38 C 2 18 20 6 39 13 C 48 -8 82 -10 94 13 C 118 4 140 18 141 40 C 139 57 125 64 105 64 H 28 C 10 64 -1 55 0 38 Z" transform="translate(${x} ${y}) scale(${scale})"`,level);
}
function coloringLeaf(id,x,y,angle=0,scale=1,level=2){
return coloringPart("path",id,`d="M -27 0 C -12 -24 16 -25 30 0 C 14 24 -12 24 -27 0 Z" transform="translate(${x} ${y}) rotate(${angle}) scale(${scale})"`,level);
}
function coloringFlower(cx,cy,prefix,scale=1,level=2){
const petals=[-90,-18,54,126,198].map((angle,index)=>{
const rad=angle*Math.PI/180,x=cx+Math.cos(rad)*25*scale,y=cy+Math.sin(rad)*25*scale;
return coloringPart("path",`${prefix}-petal-${index}`,`d="M ${cx} ${cy} C ${(cx-17*scale).toFixed(1)} ${(cy-20*scale).toFixed(1)} ${(x-15*scale).toFixed(1)} ${(y-16*scale).toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)} C ${(x+15*scale).toFixed(1)} ${(y+16*scale).toFixed(1)} ${(cx+17*scale).toFixed(1)} ${(cy+20*scale).toFixed(1)} ${cx} ${cy} Z"`,level);
}).join("");
return petals+coloringPart("circle",`${prefix}-heart`,`cx="${cx}" cy="${cy}" r="${15*scale}"`,level);
}
function coloringPetals(cx,cy,prefix,level=2){
return [0,60,120,180,240,300].map((angle,index)=>{
const rad=angle*Math.PI/180,x=(cx+Math.cos(rad)*27).toFixed(1),y=(cy+Math.sin(rad)*27).toFixed(1);
return coloringPart("circle",`${prefix}-petal-${index}`,`cx="${x}" cy="${y}" r="20"`,level);
}).join("")+coloringPart("circle",`${prefix}-heart`,`cx="${cx}" cy="${cy}" r="17"`,level);
}
function coloringStars(points,prefix,level=2){
return points.map(([x,y,r],index)=>coloringPart("path",`${prefix}-${index}`,
`d="M ${x} ${y-r} L ${x+r*.24} ${y-r*.25} L ${x+r} ${y} L ${x+r*.24} ${y+r*.25} L ${x} ${y+r} L ${x-r*.24} ${y+r*.25} L ${x-r} ${y} L ${x-r*.24} ${y-r*.25} Z"`,level)).join("");
}
function coloringRefined(id,svg){
return svg.replace('class="coloring-sheet"',`class="coloring-sheet" data-scene="${id}"`);
}
const COLORING_PAGES=Object.freeze([
{id:"butterfly",theme:"animals",years:[1,2],title:{nl:"Blije vlinder",en:"Happy butterfly"},svg:coloringRefined("butterfly",proButterfly())},
{id:"autumn",theme:"seasons",years:[1,2],title:{nl:"Herfstboom",en:"Autumn tree"},svg:coloringRefined("autumn",proAutumn())},
{id:"cake",theme:"culture",years:[1,2],title:{nl:"Feesttaart",en:"Party cake"},svg:coloringRefined("cake",proCake())},
{id:"farmyard",theme:"animals",years:[1,2],title:{nl:"Boerderijvrienden",en:"Farmyard friends"},svg:coloringRefined("farmyard",proFarmyard())},
{id:"rainbow-weather",theme:"seasons",years:[1,2],title:{nl:"Regenboogweer",en:"Rainbow weather"},svg:coloringRefined("rainbow-weather",proRainbowWeather())},
{id:"construction-site",theme:"science",years:[1,2],title:{nl:"Grote graafmachine",en:"Big excavator"},svg:coloringRefined("construction-site",proConstruction())},
{id:"forest-picnic",theme:"nature",years:[1,2],title:{nl:"Picknick in het bos",en:"Forest picnic"},svg:coloringRefined("forest-picnic",proForestPicnic())},
{id:"ocean",theme:"animals",years:[3,4],title:{nl:"Vrienden in zee",en:"Ocean friends"},svg:coloringRefined("ocean",proOcean())},
{id:"garden",theme:"nature",years:[3,4],title:{nl:"Kleine tuindieren",en:"Little garden animals"},svg:coloringRefined("garden",proGarden())},
{id:"windmill",theme:"culture",years:[3,4],title:{nl:"Molen in de polder",en:"Windmill in the polder"},svg:coloringRefined("windmill",proWindmill())},
{id:"dinosaur-valley",theme:"science",years:[3,4],title:{nl:"Dinosaurusvallei",en:"Dinosaur valley"},svg:coloringRefined("dinosaur-valley",proDinosaurValley())},
{id:"spring-nest",theme:"seasons",years:[3,4],title:{nl:"Vogelnest in de lente",en:"Spring bird nest"},svg:coloringRefined("spring-nest",proSpringNest())},
{id:"fire-station",theme:"culture",years:[3,4],title:{nl:"Brandweerkazerne",en:"Fire station"},svg:coloringRefined("fire-station",proFireStation())},
{id:"bee-meadow",theme:"nature",years:[3,4],title:{nl:"Bijen in de bloemenwei",en:"Bees in the meadow"},svg:coloringRefined("bee-meadow",proBeeMeadow())},
{id:"rocket",theme:"science",years:[5,6],title:{nl:"Raket door de ruimte",en:"Rocket through space"},svg:coloringRefined("rocket",proRocket())},
{id:"rainforest",theme:"nature",years:[5,6],title:{nl:"Regenwoud",en:"Rainforest"},svg:coloringRefined("rainforest",proRainforest())},
{id:"castle",theme:"culture",years:[5,6],title:{nl:"Kasteel en dorp",en:"Castle and village"},svg:coloringRefined("castle",proCastle())},
{id:"coral-reef",theme:"animals",years:[5,6],title:{nl:"Koraalrif",en:"Coral reef"},svg:coloringRefined("coral-reef",proCoralReef())},
{id:"mountain-camp",theme:"seasons",years:[5,6],title:{nl:"Bergkamp",en:"Mountain camp"},svg:coloringRefined("mountain-camp",proMountainCamp())},
{id:"robot-workshop",theme:"science",years:[5,6],title:{nl:"Robotwerkplaats",en:"Robot workshop"},svg:coloringRefined("robot-workshop",proRobotWorkshop())},
{id:"canal-street",theme:"culture",years:[5,6],title:{nl:"Straat langs de gracht",en:"Canal street"},svg:coloringRefined("canal-street",proCanalStreet())},
{id:"four-seasons",theme:"seasons",years:[7,8],title:{nl:"Boom van vier seizoenen",en:"Four seasons tree"},svg:coloringRefined("four-seasons",proFourSeasons())},
{id:"eco-city",theme:"science",years:[7,8],title:{nl:"Duurzame stad",en:"Sustainable city"},svg:coloringRefined("eco-city",proEcoCity())},
{id:"mandala",theme:"culture",years:[7,8],title:{nl:"Geometrische mandala",en:"Geometric mandala"},svg:coloringRefined("mandala",proMandala())},
{id:"ecosystem",theme:"nature",years:[7,8],title:{nl:"Leven rond de vijver",en:"Life around the pond"},svg:coloringRefined("ecosystem",proEcosystem())},
{id:"solar-observatory",theme:"science",years:[7,8],title:{nl:"Sterrenwacht",en:"Solar observatory"},svg:coloringRefined("solar-observatory",proSolarObservatory())},
{id:"savanna-web",theme:"animals",years:[7,8],title:{nl:"Leven op de savanne",en:"Life on the savanna"},svg:coloringRefined("savanna-web",proSavannaWeb())},
{id:"japanese-garden",theme:"culture",years:[7,8],title:{nl:"Japanse tuin",en:"Japanese garden"},svg:coloringRefined("japanese-garden",proJapaneseGarden())}
]);
function coloringPagesFor(yearGroup,theme="all"){
const year=Math.max(1,Math.min(8,Number(yearGroup)||4));
return COLORING_PAGES.filter(page=>year>=page.years[0]&&year<=page.years[1]&&(theme==="all"||page.theme===theme));
}
function coloringBrushWidth(size,pressure,pointerType){
const base=Math.max(2,Math.min(18,Number(size)||6));
return pointerType==="pen"&&pressure>0?base*(.45+pressure*1.2):base;
}
function coloringSafeColor(value,fallback=COLORING_COLORS[1]){
return /^#[0-9a-f]{6}$/i.test(String(value||""))?String(value).toLowerCase():fallback;
}
function coloringInkOverlay(svg){
const overlay=svg.cloneNode(true);overlay.setAttribute("class","coloring-ink-svg coloring-ink-overlay");
overlay.querySelectorAll(".coloring-recognition").forEach(node=>node.remove());
overlay.querySelectorAll("[data-fillable]").forEach(node=>{
node.removeAttribute("data-fillable");node.removeAttribute("data-part");node.setAttribute("fill","#fff");
});
return overlay;
}
function mountColoring(root,initState,opts={}){
const yearGroup=Math.max(1,Math.min(8,Number(initState&&initState.yearGroup)||4));
const level=Math.max(1,Math.min(3,Number(initState&&initState.level)||2));
const savedTool=initState&&initState.tool==="pencil"?"brush":initState&&initState.tool;
let tool=["brush","bucket","eraser"].includes(savedTool)?savedTool:"brush";
let color=coloringSafeColor(initState&&initState.color);
let size=[4,7,12].includes(Number(initState&&initState.size))?Number(initState.size):7;
let theme=COLORING_THEME_KEYS[initState&&initState.theme]?initState.theme:"all";
let pageId=String(initState&&initState.pageId||"");
let fills=initState&&initState.pageId===pageId&&initState.fills&&typeof initState.fills==="object"?{...initState.fills}:{};
let completed=!!(initState&&initState.completed),restoreInk=initState&&initState.ink;
root.innerHTML=`<div class="coloring-app">
<div class="coloring-choices">
<label><span class="coloring-theme-label"></span><select class="coloring-theme"></select></label>
<label><span class="coloring-page-label"></span><select class="coloring-page"></select></label>
<span class="coloring-fit"></span>
</div>
<div class="coloring-tools">
<div class="coloring-tool-buttons"></div>
<div class="coloring-palette"></div>
<label class="coloring-custom"><span></span><input type="color"></label>
<div class="coloring-sizes"></div>
<button class="coloring-reset" type="button">↺</button>
<button class="coloring-done tbtn" type="button"></button>
</div>
<div class="coloring-stage"><div class="coloring-paper" role="img"><div class="coloring-art"></div><canvas width="800" height="600"></canvas><div class="coloring-ink" aria-hidden="true"></div></div></div>
<div class="coloring-hint" aria-live="polite"></div>
</div>`;
const app=root.querySelector(".coloring-app"),themeSel=root.querySelector(".coloring-theme"),pageSel=root.querySelector(".coloring-page");
const art=root.querySelector(".coloring-art"),inkHost=root.querySelector(".coloring-ink"),paper=root.querySelector(".coloring-paper"),canvas=root.querySelector("canvas"),ctx=canvas.getContext("2d");
const toolHost=root.querySelector(".coloring-tool-buttons"),palette=root.querySelector(".coloring-palette"),sizes=root.querySelector(".coloring-sizes");
const custom=root.querySelector(".coloring-custom input"),reset=root.querySelector(".coloring-reset"),done=root.querySelector(".coloring-done"),hint=root.querySelector(".coloring-hint");
const TOOLS=[["brush","🖌️","colorToolBrush"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]];
TOOLS.forEach(([id,icon])=>{const button=document.createElement("button"),symbol=document.createElement("span"),label=document.createElement("span");button.type="button";button.dataset.tool=id;symbol.className="coloring-tool-icon";symbol.textContent=icon;symbol.setAttribute("aria-hidden","true");label.className="coloring-tool-label";button.append(symbol,label);button.onclick=()=>{tool=id;refreshTools()};toolHost.appendChild(button)});
COLORING_COLORS.forEach((value,index)=>{const button=document.createElement("button");button.type="button";button.className="coloring-color";button.dataset.color=value;button.dataset.index=index+1;const dot=document.createElement("i");dot.style.background=value;button.appendChild(dot);button.onclick=()=>{color=value;custom.value=value;if(tool==="eraser")tool="brush";refreshTools()};palette.appendChild(button)});
[4,7,12].forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-size";button.dataset.size=value;const dot=document.createElement("i");dot.style.width=dot.style.height=(value+5)+"px";button.appendChild(dot);button.onclick=()=>{size=value;refreshTools()};sizes.appendChild(button)});
custom.value=color;custom.oninput=()=>{color=coloringSafeColor(custom.value,color);if(tool==="eraser")tool="brush";refreshTools()};
function pageTitle(page){return page.title[LANG]||page.title.nl}
function eligibleThemes(){return [...new Set(coloringPagesFor(yearGroup).map(page=>page.theme))]}
function currentPages(){return coloringPagesFor(yearGroup,theme)}
function rebuildThemeOptions(){
const previous=theme;themeSel.replaceChildren();
const all=new Option(T("colorThemeAll"),"all");themeSel.add(all);
eligibleThemes().forEach(id=>themeSel.add(new Option(T(COLORING_THEME_KEYS[id]),id)));
theme=themeSel.querySelector(`option[value="${previous}"]`)?previous:"all";themeSel.value=theme;
}
function rebuildPageOptions(prefer=pageId){
const pages=currentPages();pageSel.replaceChildren();
pages.forEach(page=>pageSel.add(new Option(pageTitle(page),page.id)));
pageId=pages.some(page=>page.id===prefer)?prefer:(pages[0]&&pages[0].id)||"";pageSel.value=pageId;
}
function clearInk(){ctx.clearRect(0,0,canvas.width,canvas.height)}
function loadInk(url){
clearInk();if(!url)return;
const image=new Image();image.onload=()=>{clearInk();ctx.drawImage(image,0,0,canvas.width,canvas.height)};image.src=url;
}
function renderPage(keepState){
const page=COLORING_PAGES.find(item=>item.id===pageId)||currentPages()[0];if(!page)return;
pageId=page.id;art.innerHTML=page.svg;
const svg=art.querySelector("svg");paper.setAttribute("aria-label",pageTitle(page));
svg.querySelectorAll("[data-level]").forEach(node=>node.toggleAttribute("hidden",Number(node.dataset.level)>level));
if(!keepState){fills={};completed=false;restoreInk=null;clearInk()}
svg.querySelectorAll("[data-fillable][data-part]").forEach(node=>{
const saved=coloringSafeColor(fills[node.dataset.part],"");if(saved)node.setAttribute("fill",saved);
});
inkHost.replaceChildren(coloringInkOverlay(svg));
if(keepState&&restoreInk){loadInk(restoreInk);restoreInk=null}
refreshTools();updateTexts();
}
function updateTexts(){
root.querySelector(".coloring-theme-label").textContent=T("colorTheme");
root.querySelector(".coloring-page-label").textContent=T("colorPage");
root.querySelector(".coloring-fit").textContent=T("colorProfile").replace("{year}",yearGroup).replace("{level}",level);
toolHost.setAttribute("role","toolbar");toolHost.setAttribute("aria-label",T("penExtras"));palette.setAttribute("role","group");palette.setAttribute("aria-label",T("paletteColors"));sizes.setAttribute("role","group");sizes.setAttribute("aria-label",T("penSize"));
TOOLS.forEach(([id,,key])=>{const button=toolHost.querySelector(`[data-tool="${id}"]`),text=T(key);button.title=text;button.setAttribute("aria-label",text);button.querySelector(".coloring-tool-label").textContent=text});
palette.querySelectorAll("button").forEach(button=>button.setAttribute("aria-label",`${T("penColor")} ${button.dataset.index}`));
custom.parentElement.querySelector("span").textContent=T("customColor");
reset.title=T("colorReset");reset.setAttribute("aria-label",T("colorReset"));
done.textContent=completed?"✓ "+T("colorFinished"):T("colorDone");
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorBrushHint");
const selected=pageId;rebuildPageOptions(selected);
}
function refreshTools(){
toolHost.querySelectorAll("button").forEach(button=>{const on=button.dataset.tool===tool;button.classList.toggle("on",on);button.setAttribute("aria-pressed",String(on))});
palette.querySelectorAll("button").forEach(button=>button.classList.toggle("on",button.dataset.color===color&&tool!=="eraser"));
sizes.querySelectorAll("button").forEach(button=>{button.classList.toggle("on",Number(button.dataset.size)===size);button.title=T("penSize")});
app.dataset.tool=tool;canvas.style.pointerEvents=tool==="bucket"?"none":"auto";art.style.pointerEvents=tool==="bucket"?"auto":"none";
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorBrushHint");
}
themeSel.onchange=()=>{theme=themeSel.value;rebuildPageOptions("");renderPage(false)};
pageSel.onchange=()=>{pageId=pageSel.value;renderPage(false)};
art.addEventListener("click",event=>{
if(tool!=="bucket")return;const part=event.target.closest("[data-fillable][data-part]");if(!part||part.hasAttribute("hidden"))return;
part.setAttribute("fill",color);fills[part.dataset.part]=color;completed=false;updateTexts();
});
function partBelow(event){
const canvasEvents=canvas.style.pointerEvents,artEvents=art.style.pointerEvents;
let part=null;
try{
canvas.style.pointerEvents="none";art.style.pointerEvents="auto";
const target=document.elementFromPoint(event.clientX,event.clientY);
part=target&&typeof target.closest==="function"?target.closest("[data-fillable][data-part]"):null;
}finally{
canvas.style.pointerEvents=canvasEvents;art.style.pointerEvents=artEvents;
}
return part&&art.contains(part)&&!part.hasAttribute("hidden")?part:null;
}
function eraseFillAt(event){
if(tool!=="eraser")return false;
const part=partBelow(event);if(!part||!Object.prototype.hasOwnProperty.call(fills,part.dataset.part))return false;
delete fills[part.dataset.part];part.setAttribute("fill","#fff");completed=false;return true;
}
const point=event=>{const rect=canvas.getBoundingClientRect();return{x:(event.clientX-rect.left)*canvas.width/rect.width,y:(event.clientY-rect.top)*canvas.height/rect.height}};
let drawing=false,last=null,mid=null;
function brushSegment(next,event){
const width=coloringBrushWidth(size,event.pressure,event.pointerType),erase=tool==="eraser";
if(erase)eraseFillAt(event);
ctx.save();ctx.globalCompositeOperation=erase?"destination-out":"source-over";ctx.strokeStyle=color;ctx.globalAlpha=erase?1:.72;ctx.lineCap="round";ctx.lineJoin="round";ctx.lineWidth=erase?width*3.5:width;
const nextMid={x:(last.x+next.x)/2,y:(last.y+next.y)/2};ctx.beginPath();ctx.moveTo(mid?mid.x:last.x,mid?mid.y:last.y);ctx.quadraticCurveTo(last.x,last.y,nextMid.x,nextMid.y);ctx.stroke();
ctx.restore();last=next;mid=nextMid;
}
canvas.addEventListener("pointerdown",event=>{if(tool==="bucket"||(event.pointerType==="mouse"&&event.button!==0))return;event.preventDefault();canvas.setPointerCapture(event.pointerId);drawing=true;last=point(event);mid=null;brushSegment({x:last.x+.01,y:last.y+.01},event);completed=false;updateTexts()});
canvas.addEventListener("pointermove",event=>{if(!drawing)return;(event.getCoalescedEvents?event.getCoalescedEvents():[event]).forEach(sample=>brushSegment(point(sample),sample))});
const stop=()=>{drawing=false;last=mid=null};canvas.addEventListener("pointerup",stop);canvas.addEventListener("pointercancel",stop);
reset.onclick=()=>{fills={};completed=false;clearInk();renderPage(false)};
done.onclick=()=>{completed=true;updateTexts();if(typeof opts.onProgress==="function")opts.onProgress({attempts:1,correct:1,stars:1,detail:{question:pageTitle(COLORING_PAGES.find(page=>page.id===pageId)),answer:T("colorFinished"),correct:true}})};
document.addEventListener("langchange",()=>{rebuildThemeOptions();updateTexts();renderPage(true)});
rebuildThemeOptions();rebuildPageOptions(pageId);renderPage(true);
return{getState:()=>{
let ink=null;try{ink=canvas.toDataURL("image/png")}catch(_){/* een leeg canvas blijft gewoon herstelbaar */}
return{theme,pageId,fills:{...fills},ink,tool,color,size,completed,yearGroup,level,managedLearningLevel:!!(initState&&initState.managedLearningLevel)};
}};
}