All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
Kwast en verfemmer werkten al, maar het resultaat was onzichtbaar: de contourlaag zette fill="#fff" op elk vak en vertrouwde op mix-blend-mode:multiply om dat onschadelijk te maken. Doordat .coloring-ink zelf een stacking context is (position+z-index), vormt die een geisoleerde blendgroep zonder achtergrond, en werd het wit gewoon dekkend over de hele tekening gelegd. Live nagemeten met headless Chromium. - Tekenmodel omgezet naar een echte schildersvolgorde in een enkele SVG: elk vak wordt uitgeklapt naar vak -> verflaag -> contour, zodat verf zichtbaar blijft en latere vakken de verf en contour van eerdere vakken afdekken. Daarmee kijk je ook niet meer door figuren heen. - Canvas vervangen door SVG-streken: geen opschalingsonscherpte meer en geen donkere naden (dekking staat per streek op de groep, niet per segment). - Kwast blijft binnen de lijnen als hulp, gestuurd door niveau: groep 1-4 op niveau 1 en 2, groep 5-8 op niveau 1, daarboven vrij schilderen. - Verfemmer dekt een vak volledig; de gum pelt de bovenste laag af (eerst de vulling, daarna de streken eronder). - Kleurwijzer toegevoegd: op niveau 1 laat een stip per vak de voorgestelde kleur zien, op niveau 2 op te vragen met een knop, op niveau 3 uit. - Niveau sloopt de tekening niet meer: data-tier staat alleen op groepen en detaillijnen, dus een rekwisiet verdwijnt als geheel en de plaat is op elk niveau een complete tekening. Niveau 3 voegt alleen fijn lijnwerk toe. - Atlas hertekend naar 20 platen, 5 per groepenband en 1 per thema. Twee lijndiktes, vakken op maat van de band, geen evenodd-gaten, en composities met voor-, midden- en achtergrond. - partBelow/elementFromPoint en het omzetten van pointer-events vervallen: aanwijzen gebeurt meetkundig met isPointInFill/isPointInStroke. - langchange-listener ruimt zichzelf op na demontage. - Staat bewaart nu streken in plaats van een PNG; oud werk met een ink-PNG blijft zichtbaar. - Tests: asserties die de bug vastzetten verwijderd en vervangen door een regressietest die verbiedt dat CSS een fill op een kleurvak zet, plus controles op gesloten vakken, geen transform, tier-regels, vakaantallen per niveau en de clip-matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YErxhic5w9K57zmt8Q1uS
467 lines
28 KiB
JavaScript
467 lines
28 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`;
|
|
}
|
|
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()}
|
|
]);
|
|
|
|
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-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-guide-btn" type="button">🎯</button>
|
|
<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></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-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)
|
|
})};
|
|
}
|