All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 46s
- Nieuwe kleurplaat op verzoek: rondere, zachtere dierenstijl geïnspireerd op publiek-domein referentiebeelden (openclipart.org, bekeken maar niet overgenomen), niet de exacte scenes uit de aangeleverde voorbeelden - Egel getekend met een scherpe, doorlopende zigzag-stekelrand als onderdeel van de romp zelf, in plaats van losse ronde "bladvorm"-plukjes erbovenop - dat laatste las als bloemblaadjes, niet als stekels - Vier poten met een ronde poot-vorm en teentjes in plaats van rechte blokjes; buik is één rustige boog zodat de poten er voorspelbaar onderuit steken zonder door elkaar of de buiklijn heen te kruisen - Boom herbouwd: stam die breed begint bij de grond en versmalt naar boven (net als de getapte dierenpoten van eerder deze sessie, maar omgekeerd), met een zichtbare splitsing in takken en een kroon van drie overlappende ronde plukken in plaats van één gladde ovaal op een paal - Testinvariant "20 platen, 5 per groepenband" versoepeld naar "minstens 20/5" zodat een proefplaat als bonus kan bestaan zonder de gecureerde kernset aan te tasten Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
485 lines
29 KiB
JavaScript
485 lines
29 KiB
JavaScript
/* teach - thematische kleurplaten met kwast en verfemmer
|
|
|
|
Tekenmodel: één SVG per plaat in de 800x600-ruimte, met een echte
|
|
schildersvolgorde - de DOM-volgorde ís de tekenvolgorde. Elk kleurvak wordt
|
|
bij het renderen uitgeklapt naar een drieluik:
|
|
|
|
<path class="coloring-region"> het vak zelf: papierwit of de gekozen kleur
|
|
<g class="coloring-wash"> de kwaststreken in dat vak
|
|
<path class="coloring-outline"> de contour, dus altijd scherp boven de verf
|
|
|
|
Omdat een leeg vak papierwit is en niet doorzichtig, dekt een vak dat later
|
|
komt de verf én de contour van eerdere vakken af. Daarmee lost dit model twee
|
|
dingen in één keer op: verf blijft zichtbaar, en je kijkt niet meer door
|
|
figuren heen. Eerdere versies legden er één witte contourlaag met
|
|
mix-blend-mode over; die blendde door haar eigen stacking context niet en
|
|
verborg alle verf. Er staat nu dus nergens meer een dekkende laag bóven de
|
|
verf van hetzelfde vak.
|
|
|
|
Daarnaast: <g class="coloring-free"> voor vrije streken (bovenaan, alleen op
|
|
de hogere niveaus) en <g class="coloring-guide"> voor de kleurwijzer.
|
|
|
|
Alle aanwijzing gebeurt meetkundig met isPointInFill/isPointInStroke vanaf
|
|
het papier-element, dus zonder pointer-events om te zetten en zonder
|
|
elementFromPoint. */
|
|
"use strict";
|
|
|
|
const COLORING_SVG_NS="http://www.w3.org/2000/svg";
|
|
const COLORING_WASH_ALPHA=.72;
|
|
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"
|
|
]);
|
|
let COLORING_UID=0;
|
|
|
|
/* --- tekenprimitieven ---------------------------------------------------
|
|
Een kleurvak is een gesloten vorm zónder transform: hetzelfde pad dient
|
|
ook als clip voor de kwast. data-tier hoort op een groep of op een
|
|
detaillijn - nooit op een los vak - zodat een rekwisiet altijd als geheel
|
|
verdwijnt en de plaat op elk niveau een complete tekening blijft. */
|
|
/* De vulling staat als presentatie-attribuut in de vorm zelf, nooit in CSS:
|
|
een CSS-regel wint van een attribuut, en dat is precies hoe eerdere versies
|
|
de verf onbedoeld wegpoetsten.
|
|
|
|
Een leeg vak is papierwit, niet doorzichtig. Elk vak staat direct vóór zijn
|
|
eigen verflaag, dus vakken die later komen dekken de verf van eerdere vakken
|
|
af - zo blijft "binnen de lijnen" ook echt binnen de lijnen. Wat er staat
|
|
blijft dus zichtbaar; alleen wat er vóór ligt dekt af. */
|
|
const COLORING_PAPER="#ffffff";
|
|
function coloringRegion(tag,id,attrs,hint,cls){
|
|
return `<${tag} class="coloring-region ${cls}" data-fillable="true" data-part="${id}"${hint?` data-hint="${hint}"`:""} fill="${COLORING_PAPER}" ${attrs}/>`;
|
|
}
|
|
function coloringContour(id,d,hint){return coloringRegion("path",id,`d="${d}"`,hint,"coloring-contour")}
|
|
function coloringFlat(id,d,hint){return coloringRegion("path",id,`d="${d}"`,hint,"coloring-flat")}
|
|
function coloringCircle(id,cx,cy,r,hint,cls="coloring-flat"){
|
|
return coloringRegion("circle",id,`cx="${cx}" cy="${cy}" r="${r}"`,hint,cls);
|
|
}
|
|
function coloringEllipse(id,cx,cy,rx,ry,hint,cls="coloring-flat"){
|
|
return coloringRegion("ellipse",id,`cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"`,hint,cls);
|
|
}
|
|
function coloringRect(id,x,y,w,h,hint,rx=0,cls="coloring-flat"){
|
|
return coloringRegion("rect",id,`x="${x}" y="${y}" width="${w}" height="${h}"${rx?` rx="${rx}"`:""}`,hint,cls);
|
|
}
|
|
function coloringBackdrop(id,hint){
|
|
return coloringRegion("rect",id,'x="4" y="4" width="792" height="592" rx="28"',hint,"coloring-backdrop");
|
|
}
|
|
function coloringLine(d,tier=1){
|
|
return `<path class="coloring-detail"${tier>1?` data-tier="${tier}"`:""} fill="none" d="${d}"/>`;
|
|
}
|
|
function coloringDot(cx,cy,r=5,tier=1){
|
|
return `<circle class="coloring-detail coloring-solid"${tier>1?` data-tier="${tier}"`:""} cx="${cx}" cy="${cy}" r="${r}"/>`;
|
|
}
|
|
function coloringGroup(id,content,tier=1){
|
|
return `<g class="coloring-figure" data-figure="${id}"${tier>1?` data-tier="${tier}"`:""}>${content}</g>`;
|
|
}
|
|
/* Vormgeneratoren met absolute coördinaten. Bewust géén transform: hetzelfde
|
|
pad moet ook als clip voor de kwast kunnen dienen. */
|
|
const coloringRound=value=>Math.round(value*10)/10;
|
|
function coloringCloudPath(x,y,w,h){
|
|
const n=coloringRound;
|
|
return `M ${n(x)} ${n(y+h)} C ${n(x-h*.16)} ${n(y+h*.44)} ${n(x+w*.05)} ${n(y+h*.06)} ${n(x+w*.27)} ${n(y+h*.28)}`+
|
|
` C ${n(x+w*.33)} ${n(y-h*.2)} ${n(x+w*.67)} ${n(y-h*.2)} ${n(x+w*.73)} ${n(y+h*.28)}`+
|
|
` C ${n(x+w*.95)} ${n(y+h*.06)} ${n(x+w+h*.16)} ${n(y+h*.44)} ${n(x+w)} ${n(y+h)}`+
|
|
` C ${n(x+w*.72)} ${n(y+h*1.14)} ${n(x+w*.28)} ${n(y+h*1.14)} ${n(x)} ${n(y+h)} Z`;
|
|
}
|
|
function coloringStarPath(cx,cy,r){
|
|
const n=coloringRound,points=[];
|
|
for(let step=0;step<10;step++){
|
|
const angle=(-90+step*36)*Math.PI/180,radius=step%2?r*.42:r;
|
|
points.push(`${n(cx+Math.cos(angle)*radius)} ${n(cy+Math.sin(angle)*radius)}`);
|
|
}
|
|
return `M ${points.join(" L ")} Z`;
|
|
}
|
|
function coloringPetalPath(cx,cy,angle,len,wide){
|
|
const n=coloringRound,rad=angle*Math.PI/180,ux=Math.cos(rad),uy=Math.sin(rad);
|
|
const tipX=cx+ux*len,tipY=cy+uy*len,nx=-uy*wide,ny=ux*wide;
|
|
return `M ${n(cx)} ${n(cy)} C ${n(cx+nx+ux*len*.28)} ${n(cy+ny+uy*len*.28)} ${n(tipX+nx*.52)} ${n(tipY+ny*.52)} ${n(tipX)} ${n(tipY)}`+
|
|
` C ${n(tipX-nx*.52)} ${n(tipY-ny*.52)} ${n(cx-nx+ux*len*.28)} ${n(cy-ny+uy*len*.28)} ${n(cx)} ${n(cy)} Z`;
|
|
}
|
|
/* Getapt dierenbeen: schouderbreedte boven, smal bij de enkel, breder bij de
|
|
hoef, met een lichte knik zodat het geen rechte kokerpoot wordt. */
|
|
function coloringLegPath(cx,topY,hoofY,topW,kneeW,hoofW,bend=0){
|
|
const n=coloringRound,kneeY=topY+(hoofY-topY)*.58;
|
|
const l1=cx-topW/2,l2=cx-kneeW/2+bend,l3=cx-hoofW/2+bend;
|
|
const r1=cx+topW/2,r2=cx+kneeW/2+bend,r3=cx+hoofW/2+bend;
|
|
const midTop=topY+(kneeY-topY)*.5,midHoof=kneeY+(hoofY-kneeY)*.5;
|
|
return `M ${n(l1)} ${n(topY)} C ${n(l1)} ${n(midTop)} ${n(l2)} ${n(kneeY-(kneeY-topY)*.3)} ${n(l2)} ${n(kneeY)}`+
|
|
` C ${n(l2)} ${n(midHoof)} ${n(l3)} ${n(hoofY-(hoofY-kneeY)*.3)} ${n(l3)} ${n(hoofY)}`+
|
|
` L ${n(r3)} ${n(hoofY)}`+
|
|
` C ${n(r3)} ${n(hoofY-(hoofY-kneeY)*.3)} ${n(r2)} ${n(midHoof)} ${n(r2)} ${n(kneeY)}`+
|
|
` C ${n(r2)} ${n(kneeY-(kneeY-topY)*.3)} ${n(r1)} ${n(midTop)} ${n(r1)} ${n(topY)} Z`;
|
|
}
|
|
function coloringScene(id,content){
|
|
return `<svg class="coloring-sheet" data-scene="${id}" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
|
<defs class="coloring-clips"></defs>
|
|
<g class="coloring-flats">${content}</g>
|
|
</svg>`;
|
|
}
|
|
|
|
const COLORING_PAGES=Object.freeze([
|
|
{id:"butterfly",theme:"animals",years:[1,2],title:{nl:"Vlinder op de klaproos",en:"Butterfly on the poppy"},svg:plateButterfly()},
|
|
{id:"autumn",theme:"seasons",years:[1,2],title:{nl:"Herfstboom",en:"Autumn tree"},svg:plateAutumn()},
|
|
{id:"cake",theme:"culture",years:[1,2],title:{nl:"Feesttaart",en:"Party cake"},svg:plateCake()},
|
|
{id:"forest-picnic",theme:"nature",years:[1,2],title:{nl:"Picknick in het bos",en:"Forest picnic"},svg:platePicnic()},
|
|
{id:"construction-site",theme:"science",years:[1,2],title:{nl:"Grote graafmachine",en:"Big excavator"},svg:plateExcavator()},
|
|
{id:"ocean",theme:"animals",years:[3,4],title:{nl:"Vrienden in zee",en:"Ocean friends"},svg:plateOcean()},
|
|
{id:"garden",theme:"nature",years:[3,4],title:{nl:"Kleine tuindieren",en:"Little garden animals"},svg:plateGarden()},
|
|
{id:"windmill",theme:"culture",years:[3,4],title:{nl:"Molen in de polder",en:"Windmill in the polder"},svg:plateWindmill()},
|
|
{id:"dinosaur-valley",theme:"science",years:[3,4],title:{nl:"Dinosaurusvallei",en:"Dinosaur valley"},svg:plateDinosaur()},
|
|
{id:"spring-nest",theme:"seasons",years:[3,4],title:{nl:"Vogelnest in de lente",en:"Spring bird nest"},svg:plateNest()},
|
|
{id:"rocket",theme:"science",years:[5,6],title:{nl:"Raket door de ruimte",en:"Rocket through space"},svg:plateRocket()},
|
|
{id:"rainforest",theme:"nature",years:[5,6],title:{nl:"Regenwoud",en:"Rainforest"},svg:plateRainforest()},
|
|
{id:"castle",theme:"culture",years:[5,6],title:{nl:"Kasteel aan de rivier",en:"Castle by the river"},svg:plateCastle()},
|
|
{id:"coral-reef",theme:"animals",years:[5,6],title:{nl:"Koraalrif",en:"Coral reef"},svg:plateReef()},
|
|
{id:"mountain-camp",theme:"seasons",years:[5,6],title:{nl:"Bergkamp",en:"Mountain camp"},svg:plateCamp()},
|
|
{id:"four-seasons",theme:"seasons",years:[7,8],title:{nl:"Boom van vier seizoenen",en:"Four seasons tree"},svg:plateSeasons()},
|
|
{id:"eco-city",theme:"science",years:[7,8],title:{nl:"Duurzame stad",en:"Sustainable city"},svg:plateEcoCity()},
|
|
{id:"mandala",theme:"culture",years:[7,8],title:{nl:"Geometrische mandala",en:"Geometric mandala"},svg:plateMandala()},
|
|
{id:"ecosystem",theme:"nature",years:[7,8],title:{nl:"Leven rond de vijver",en:"Life around the pond"},svg:plateEcosystem()},
|
|
{id:"savanna-web",theme:"animals",years:[7,8],title:{nl:"Leven op de savanne",en:"Life on the savanna"},svg:plateSavanna()},
|
|
{id:"hedgehog-forest",theme:"nature",years:[1,2],title:{nl:"Egeltje in het bos",en:"Little hedgehog in the forest"},svg:plateHedgehog()}
|
|
]);
|
|
|
|
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;
|
|
}
|
|
/* Binnen de lijnen blijven is hulp, niet een beperking: groep 1-4 krijgt het op
|
|
niveau 1 en 2, groep 5-8 alleen op niveau 1. Op het hoogste niveau schildert
|
|
iedereen vrij over de plaat. */
|
|
function coloringClipsStrokes(yearGroup,level){
|
|
return Number(level)===1||(Number(yearGroup)<=4&&Number(level)===2);
|
|
}
|
|
function coloringVisible(node){return !node.hasAttribute("hidden")&&!node.closest("[hidden]")}
|
|
function coloringRegionAt(sheet,point){
|
|
const nodes=sheet.querySelectorAll(".coloring-flats [data-part]");
|
|
for(let index=nodes.length-1;index>=0;index--){
|
|
const node=nodes[index];
|
|
if(!coloringVisible(node)||typeof node.isPointInFill!=="function")continue;
|
|
try{if(node.isPointInFill(point))return node}catch(_){/* vorm zonder meetkunde slaan we over */}
|
|
}
|
|
return null;
|
|
}
|
|
function coloringStrokeAt(sheet,point){
|
|
const paths=sheet.querySelectorAll(".coloring-stroke path");
|
|
for(let index=paths.length-1;index>=0;index--){
|
|
try{if(paths[index].isPointInStroke(point))return paths[index].closest(".coloring-stroke")}catch(_){}
|
|
}
|
|
return null;
|
|
}
|
|
/* Wat je bovenop ziet, haalt de gum weg: de laag die het laatst getekend is
|
|
wint, dus een streek onder een gevuld vak blijft beschermd. */
|
|
function coloringTopLayer(strokeGroup,filledRegion){
|
|
if(!strokeGroup)return filledRegion||null;
|
|
if(!filledRegion)return strokeGroup;
|
|
return strokeGroup.compareDocumentPosition(filledRegion)&Node.DOCUMENT_POSITION_FOLLOWING?filledRegion:strokeGroup;
|
|
}
|
|
|
|
function mountColoring(root,initState,opts={}){
|
|
const uid=++COLORING_UID;
|
|
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||"");
|
|
const samePage=!!(initState&&initState.pageId&&String(initState.pageId)===pageId);
|
|
let fills=samePage&&initState.fills&&typeof initState.fills==="object"?{...initState.fills}:{};
|
|
let strokes=samePage&&Array.isArray(initState.strokes)?initState.strokes.slice():[];
|
|
let legacyInk=samePage&&!strokes.length&&typeof initState.ink==="string"?initState.ink:null;
|
|
let completed=!!(initState&&initState.completed);
|
|
let guide=initState&&typeof initState.guide==="boolean"?initState.guide:level===1;
|
|
const clipStrokes=coloringClipsStrokes(yearGroup,level);
|
|
|
|
root.innerHTML=`<div class="coloring-app">
|
|
<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-done tbtn" type="button"></button>
|
|
</div>
|
|
<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>
|
|
<details class="edu-settings widget-gear"><summary>⚙ <span class="coloring-settings-label"></span></summary>
|
|
<div class="edu-controls">
|
|
<button class="coloring-guide-btn" type="button">🎯</button>
|
|
<button class="coloring-reset" type="button">↺</button>
|
|
</div></details>
|
|
<div class="coloring-stage"><div class="coloring-paper" role="img"></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 paper=root.querySelector(".coloring-paper");
|
|
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");
|
|
const guideBtn=root.querySelector(".coloring-guide-btn"),hint=root.querySelector(".coloring-hint");
|
|
const TOOLS=[["brush","🖌️","colorToolBrush"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]];
|
|
let sheet=null,flats=null,freeHost=null,clips=null,guideHost=null;
|
|
|
|
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()};
|
|
guideBtn.onclick=()=>{guide=!guide;renderGuide();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();
|
|
themeSel.add(new Option(T("colorThemeAll"),"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;
|
|
}
|
|
const svgNode=name=>document.createElementNS(COLORING_SVG_NS,name);
|
|
function point(event){
|
|
const rect=paper.getBoundingClientRect();
|
|
return{x:(event.clientX-rect.left)*800/rect.width,y:(event.clientY-rect.top)*600/rect.height};
|
|
}
|
|
const domPoint=pt=>new DOMPoint(pt.x,pt.y);
|
|
|
|
/* Elk vak wordt uitgeklapt naar vak -> verflaag -> contour. De contour is een
|
|
afgeleide van het vak zelf, dus er is één bron voor de tekening en de
|
|
contour kan per definitie niets afdekken (fill="none"). */
|
|
function expandRegions(){
|
|
flats.querySelectorAll("[data-fillable][data-part]").forEach(region=>{
|
|
const wash=svgNode("g");
|
|
wash.setAttribute("class","coloring-wash");wash.dataset.for=region.dataset.part;
|
|
const outline=region.cloneNode(false);
|
|
outline.setAttribute("class",region.getAttribute("class").replace("coloring-region","coloring-outline"));
|
|
["data-fillable","data-part","data-hint","data-hint-at"].forEach(name=>outline.removeAttribute(name));
|
|
outline.setAttribute("fill","none");
|
|
region.parentNode.insertBefore(wash,region.nextSibling);
|
|
region.parentNode.insertBefore(outline,wash.nextSibling);
|
|
});
|
|
}
|
|
function clipFor(region){
|
|
const id=`cclip-${uid}-${region.dataset.part}`;
|
|
if(!clips.querySelector(`[id="${id}"]`)){
|
|
const clip=svgNode("clipPath");clip.id=id;
|
|
const shape=region.cloneNode(false);
|
|
["class","data-fillable","data-part","data-hint","data-hint-at","fill","hidden"].forEach(name=>shape.removeAttribute(name));
|
|
clip.appendChild(shape);clips.appendChild(clip);
|
|
}
|
|
return `url(#${id})`;
|
|
}
|
|
function washFor(region){
|
|
const next=region.nextElementSibling;
|
|
return next&&next.classList.contains("coloring-wash")?next:freeHost;
|
|
}
|
|
function strokePath(seg,strokeColor){
|
|
const path=svgNode("path");
|
|
path.setAttribute("fill","none");path.setAttribute("stroke",strokeColor);
|
|
path.setAttribute("stroke-width",String(seg.w));
|
|
path.setAttribute("stroke-linecap","round");path.setAttribute("stroke-linejoin","round");
|
|
path.setAttribute("d",seg.d);return path;
|
|
}
|
|
function paintStroke(stroke){
|
|
const region=stroke.part?flats.querySelector(`[data-part="${stroke.part}"]`):null;
|
|
const clipped=region&&coloringVisible(region);
|
|
const group=svgNode("g");group.setAttribute("class","coloring-stroke");group.setAttribute("opacity",String(COLORING_WASH_ALPHA));
|
|
if(clipped)group.setAttribute("clip-path",clipFor(region));
|
|
stroke.segs.forEach(seg=>group.appendChild(strokePath(seg,stroke.color)));
|
|
(clipped?washFor(region):freeHost).appendChild(group);
|
|
return group;
|
|
}
|
|
function renderGuide(){
|
|
guideHost.replaceChildren();
|
|
guideBtn.hidden=level>=3;
|
|
if(!guide||level>=3)return;
|
|
flats.querySelectorAll("[data-hint]").forEach(region=>{
|
|
if(!coloringVisible(region))return;
|
|
const filled=fills[region.dataset.part],want=region.dataset.hint;
|
|
if(filled&&filled.toLowerCase()===want.toLowerCase())return;
|
|
let cx,cy;
|
|
const at=region.dataset.hintAt;
|
|
if(at){const parts=at.split(/\s+/);cx=Number(parts[0]);cy=Number(parts[1])}
|
|
else{const box=region.getBBox();cx=box.x+box.width/2;cy=box.y+box.height/2}
|
|
const dot=svgNode("circle");
|
|
dot.setAttribute("class","coloring-guide-dot");dot.setAttribute("cx",String(Math.round(cx)));
|
|
dot.setAttribute("cy",String(Math.round(cy)));dot.setAttribute("r","13");
|
|
dot.setAttribute("fill",want);dot.setAttribute("opacity",filled?".3":"1");
|
|
guideHost.appendChild(dot);
|
|
});
|
|
}
|
|
function renderPage(keepState){
|
|
const page=COLORING_PAGES.find(item=>item.id===pageId)||currentPages()[0];if(!page)return;
|
|
pageId=page.id;paper.innerHTML=page.svg;
|
|
sheet=paper.querySelector("svg");paper.setAttribute("aria-label",pageTitle(page));
|
|
flats=sheet.querySelector(".coloring-flats");
|
|
clips=sheet.querySelector(".coloring-clips");
|
|
if(!keepState){fills={};strokes=[];legacyInk=null;completed=false}
|
|
sheet.querySelectorAll("[data-tier]").forEach(node=>node.toggleAttribute("hidden",Number(node.dataset.tier)>level));
|
|
expandRegions();
|
|
freeHost=svgNode("g");freeHost.setAttribute("class","coloring-free");flats.appendChild(freeHost);
|
|
if(legacyInk){
|
|
/* werk van vóór de SVG-verflaag: als losse afbeelding bovenop, zodat oud
|
|
gekleurd werk zichtbaar blijft */
|
|
const image=svgNode("image");image.setAttribute("class","coloring-legacy-ink");
|
|
image.setAttribute("x","0");image.setAttribute("y","0");image.setAttribute("width","800");image.setAttribute("height","600");
|
|
image.setAttribute("href",legacyInk);freeHost.appendChild(image);
|
|
}
|
|
guideHost=svgNode("g");guideHost.setAttribute("class","coloring-guide");
|
|
guideHost.setAttribute("pointer-events","none");sheet.appendChild(guideHost);
|
|
flats.querySelectorAll("[data-fillable][data-part]").forEach(node=>{
|
|
const saved=coloringSafeColor(fills[node.dataset.part],"");if(saved)node.setAttribute("fill",saved);
|
|
});
|
|
strokes.forEach(paintStroke);
|
|
renderGuide();refreshTools();updateTexts();
|
|
}
|
|
function levelHint(){return T("colorLevelHelp"+level)}
|
|
function toolHint(){
|
|
return T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorBrushHint")+" "+levelHint();
|
|
}
|
|
function updateTexts(){
|
|
root.querySelector(".coloring-settings-label").textContent=T("widgetSettings");
|
|
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"));
|
|
guideBtn.title=T("colorGuide");guideBtn.setAttribute("aria-label",T("colorGuide"));
|
|
done.textContent=completed?"✓ "+T("colorFinished"):T("colorDone");
|
|
hint.textContent=toolHint();
|
|
rebuildPageOptions(pageId);
|
|
}
|
|
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")});
|
|
guideBtn.classList.toggle("on",guide&&level<3);guideBtn.setAttribute("aria-pressed",String(guide&&level<3));
|
|
app.dataset.tool=tool;app.dataset.clip=String(clipStrokes);
|
|
hint.textContent=toolHint();
|
|
}
|
|
themeSel.onchange=()=>{theme=themeSel.value;rebuildPageOptions("");renderPage(false)};
|
|
pageSel.onchange=()=>{pageId=pageSel.value;renderPage(false)};
|
|
|
|
function fillAt(pt){
|
|
const region=coloringRegionAt(sheet,domPoint(pt));if(!region)return false;
|
|
const part=region.dataset.part;
|
|
region.setAttribute("fill",color);fills[part]=color;
|
|
/* de emmer dekt het vak volledig: kwastwerk in dit vak verdwijnt eronder */
|
|
completed=false;renderGuide();return true;
|
|
}
|
|
function eraseAt(pt){
|
|
const target=domPoint(pt);
|
|
const strokeGroup=coloringStrokeAt(sheet,target);
|
|
let filled=null;
|
|
const nodes=flats.querySelectorAll("[data-part]");
|
|
for(let index=nodes.length-1;index>=0&&!filled;index--){
|
|
const node=nodes[index];
|
|
if(!coloringVisible(node)||!Object.prototype.hasOwnProperty.call(fills,node.dataset.part))continue;
|
|
try{if(node.isPointInFill(target))filled=node}catch(_){}
|
|
}
|
|
const top=coloringTopLayer(strokeGroup,filled);
|
|
if(!top)return false;
|
|
if(top===filled){delete fills[filled.dataset.part];filled.setAttribute("fill",COLORING_PAPER)}
|
|
else{top.remove();strokes=readStrokes()}
|
|
completed=false;renderGuide();return true;
|
|
}
|
|
/* De DOM is de waarheid voor wat er staat; de staat wordt daaruit gelezen
|
|
zodat wissen en herstellen niet uit elkaar kunnen lopen. */
|
|
function readStrokes(){
|
|
return [...sheet.querySelectorAll(".coloring-stroke")].map(group=>{
|
|
const wash=group.parentNode;
|
|
return{
|
|
part:wash.classList.contains("coloring-wash")?wash.dataset.for:null,
|
|
color:group.querySelector("path")?group.querySelector("path").getAttribute("stroke"):color,
|
|
segs:[...group.querySelectorAll("path")].map(path=>({w:Number(path.getAttribute("stroke-width")),d:path.getAttribute("d")}))
|
|
};
|
|
});
|
|
}
|
|
|
|
const round=value=>Math.round(value*10)/10;
|
|
let drawing=null;
|
|
function beginStroke(pt,event){
|
|
const width=round(coloringBrushWidth(size,event.pressure,event.pointerType));
|
|
const region=clipStrokes?coloringRegionAt(sheet,domPoint(pt)):null;
|
|
drawing={
|
|
part:region?region.dataset.part:null,color,
|
|
segs:[{w:width,d:`M ${round(pt.x)} ${round(pt.y)} l .1 .1`}],
|
|
last:pt,mid:null,group:null
|
|
};
|
|
drawing.group=paintStroke({part:drawing.part,color:drawing.color,segs:drawing.segs});
|
|
}
|
|
function extendStroke(pt,event){
|
|
if(!drawing)return;
|
|
const width=round(coloringBrushWidth(size,event.pressure,event.pointerType));
|
|
const seg=drawing.segs[drawing.segs.length-1];
|
|
const nextMid={x:(drawing.last.x+pt.x)/2,y:(drawing.last.y+pt.y)/2};
|
|
const tail=` Q ${round(drawing.last.x)} ${round(drawing.last.y)} ${round(nextMid.x)} ${round(nextMid.y)}`;
|
|
if(Math.abs(width-seg.w)/seg.w>.15){
|
|
/* nieuwe penseelbreedte = nieuw subpad, aangehaakt op hetzelfde punt.
|
|
Binnen één streek staat de dekking op de groep, dus dat geeft geen naad. */
|
|
const next={w:width,d:`M ${round(drawing.last.x)} ${round(drawing.last.y)}`+tail};
|
|
drawing.segs.push(next);drawing.group.appendChild(strokePath(next,drawing.color));
|
|
}else{
|
|
seg.d+=tail;
|
|
drawing.group.lastElementChild.setAttribute("d",seg.d);
|
|
}
|
|
drawing.last=pt;drawing.mid=nextMid;
|
|
}
|
|
function endStroke(){
|
|
if(drawing)strokes=readStrokes();
|
|
drawing=null;
|
|
}
|
|
|
|
let erasing=false;
|
|
paper.addEventListener("pointerdown",event=>{
|
|
if(event.pointerType==="mouse"&&event.button!==0)return;
|
|
if(!sheet)return;
|
|
event.preventDefault();paper.setPointerCapture(event.pointerId);
|
|
const pt=point(event);
|
|
if(tool==="bucket"){fillAt(pt);updateTexts();return}
|
|
if(tool==="eraser"){erasing=true;eraseAt(pt);updateTexts();return}
|
|
beginStroke(pt,event);completed=false;updateTexts();
|
|
});
|
|
paper.addEventListener("pointermove",event=>{
|
|
if(!sheet)return;
|
|
if(erasing){eraseAt(point(event));return}
|
|
if(!drawing)return;
|
|
(event.getCoalescedEvents?event.getCoalescedEvents():[event]).forEach(sample=>extendStroke(point(sample),sample));
|
|
});
|
|
const stop=()=>{endStroke();erasing=false};
|
|
paper.addEventListener("pointerup",stop);paper.addEventListener("pointercancel",stop);
|
|
|
|
reset.onclick=()=>{fills={};strokes=[];legacyInk=null;completed=false;renderPage(true)};
|
|
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}})};
|
|
/* eigen opruiming: de widget wordt bij elke niveauwijziging opnieuw gemonteerd */
|
|
const onLang=()=>{
|
|
if(!root.isConnected){document.removeEventListener("langchange",onLang);return}
|
|
rebuildThemeOptions();renderPage(true);
|
|
};
|
|
document.addEventListener("langchange",onLang);
|
|
|
|
rebuildThemeOptions();rebuildPageOptions(pageId);renderPage(true);
|
|
return{getState:()=>({
|
|
theme,pageId,fills:{...fills},strokes:strokes.map(stroke=>({part:stroke.part,color:stroke.color,segs:stroke.segs.map(seg=>({w:seg.w,d:seg.d}))})),
|
|
tool,color,size,completed,guide,yearGroup,level,managedLearningLevel:!!(initState&&initState.managedLearningLevel)
|
|
})};
|
|
}
|