feat: voeg thematische kleurplaten toe (v0.4.65-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 44s

This commit is contained in:
Ramon 2026-07-28 22:34:15 +02:00
parent fcb117eb54
commit 0841962824
22 changed files with 611 additions and 58 deletions

View file

@ -1 +1 @@
0.4.64-beta 0.4.65-beta

View file

@ -0,0 +1,7 @@
-- Kleurplaten gebruiken dezelfde klas-standaard-met-leerling-override als de
-- andere vakgebieden. Bestaande niveaukeuzes blijven ongewijzigd; alleen de
-- toegestane waarde 'creatief' wordt aan de constraint toegevoegd.
ALTER TABLE pupil_levels DROP CONSTRAINT IF EXISTS pupil_levels_subject_check;
ALTER TABLE pupil_levels
ADD CONSTRAINT pupil_levels_subject_check
CHECK (subject IN ('taal','rekenen','world','creatief'));

46
public/css/coloring.css Normal file
View file

@ -0,0 +1,46 @@
/* Thematische kleurplaten */
.coloring-app{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--surface);color:var(--ink)}
.coloring-choices,.coloring-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--orange) 7%,var(--surface))}
.coloring-choices label{display:grid;grid-template-columns:auto minmax(120px,1fr);align-items:center;gap:6px;color:var(--muted);font-size:11px;font-weight:850}
.coloring-choices select{height:34px;max-width:230px;border:1px solid var(--line);border-radius:9px;padding:0 28px 0 9px;background:var(--surface);color:var(--ink);font:inherit;font-weight:750}
.coloring-fit{margin-left:auto;padding:5px 9px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:800;white-space:nowrap}
.coloring-tools{gap:6px;background:var(--surface)}
.coloring-tool-buttons,.coloring-palette,.coloring-sizes{display:flex;align-items:center;gap:4px;padding:3px;border-radius:11px;background:var(--surface-2)}
.coloring-tools button{width:36px;height:36px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:var(--ink);font:inherit;font-size:18px;cursor:pointer}
.coloring-tools button:is(:hover,:focus-visible),.coloring-tools button.on{background:var(--surface);box-shadow:0 1px 5px rgba(30,50,90,.18)}
.coloring-tool-buttons button.on{outline:2px solid var(--orange);outline-offset:-2px}
.coloring-color i{width:22px;height:22px;display:block;border:1px solid rgba(38,52,74,.22);border-radius:50%;box-shadow:inset 0 0 0 1px rgba(255,255,255,.18)}
.coloring-color.on{outline:2.5px solid var(--ink);outline-offset:-3px}
.coloring-size i{display:block;border-radius:50%;background:var(--ink)}
.coloring-size.on{outline:2px solid var(--orange);outline-offset:-2px}
.coloring-custom{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:10px;font-weight:800}
.coloring-custom input{width:36px;height:36px;padding:3px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer}
.coloring-tools .coloring-reset{margin-left:auto;background:var(--red-soft);color:var(--red)}
.coloring-tools .coloring-done{width:auto;min-width:112px;padding:0 12px;color:#fff;font-size:13px}
.coloring-stage{flex:1;min-height:0;overflow:auto;display:grid;place-items:center;padding:12px;background:linear-gradient(135deg,var(--surface-2),color-mix(in srgb,var(--purple) 5%,var(--surface)))}
.coloring-paper{position:relative;width:min(100%,900px);aspect-ratio:4/3;flex:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:#fff;box-shadow:0 10px 28px rgba(30,50,90,.14);isolation:isolate}
.coloring-art,.coloring-paper canvas{position:absolute;inset:0;width:100%;height:100%}
.coloring-art svg{width:100%;height:100%;display:block;background:#fff}
.coloring-art [hidden]{display:none}
.coloring-paper canvas{z-index:2;touch-action:none;cursor:crosshair;mix-blend-mode:multiply}
.coloring-app[data-tool="bucket"] .coloring-art{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Ctext y='24' font-size='23'%3E%F0%9F%AA%A3%3C/text%3E%3C/svg%3E") 6 24,pointer}
.coloring-app[data-tool="bucket"] .coloring-art [data-fillable]{pointer-events:all}
.coloring-hint{min-height:30px;padding:7px 11px;color:var(--muted);font-size:11px;font-weight:700;text-align:center;background:var(--surface)}
.widget[data-widget="coloring"]{--widget-accent:var(--orange)}
@media(max-width:620px){
.coloring-choices,.coloring-tools{padding:6px;gap:4px}
.coloring-choices label{grid-template-columns:1fr;gap:2px;flex:1;min-width:125px}
.coloring-choices select{width:100%;max-width:none;height:32px}
.coloring-fit{width:100%;margin:0;text-align:center}
.coloring-tools button{width:32px;height:32px;font-size:16px}
.coloring-color i{width:18px;height:18px}
.coloring-custom span{display:none}.coloring-custom input{width:32px;height:32px}
.coloring-tools .coloring-reset{margin-left:0}
.coloring-tools .coloring-done{min-width:90px;font-size:11px}
.coloring-stage{place-items:start center;padding:6px}
.coloring-paper{min-width:360px;border-radius:10px}
.coloring-hint{font-size:10px}
}
@media(pointer:coarse){.coloring-tools button,.coloring-custom input{min-width:40px;min-height:40px}.coloring-choices select{min-height:40px}}
@media(prefers-reduced-motion:reduce){.coloring-app *{scroll-behavior:auto!important}}

View file

@ -7,6 +7,7 @@
<link rel="icon" type="image/svg+xml" href="img/logo.svg?v=__V__"> <link rel="icon" type="image/svg+xml" href="img/logo.svg?v=__V__">
<link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="css/teach.css?v=__V__"> <link rel="stylesheet" href="css/teach.css?v=__V__">
<link rel="stylesheet" href="css/coloring.css?v=__V__">
<link rel="stylesheet" href="css/live-classroom.css?v=__V__"> <link rel="stylesheet" href="css/live-classroom.css?v=__V__">
<link rel="stylesheet" href="css/image-catalog.css?v=__V__"> <link rel="stylesheet" href="css/image-catalog.css?v=__V__">
<!-- alle app-scripts: defer = parallel downloaden, uitvoeren in dézelfde <!-- alle app-scripts: defer = parallel downloaden, uitvoeren in dézelfde
@ -49,6 +50,7 @@
<script src="js/widgets/sentence.js?v=__V__" defer></script> <script src="js/widgets/sentence.js?v=__V__" defer></script>
<script src="js/widgets/learning.js?v=__V__" defer></script> <script src="js/widgets/learning.js?v=__V__" defer></script>
<script src="js/widgets/world.js?v=__V__" defer></script> <script src="js/widgets/world.js?v=__V__" defer></script>
<script src="js/widgets/coloring.js?v=__V__" defer></script>
<script src="js/board.js?v=__V__" defer></script> <script src="js/board.js?v=__V__" defer></script>
<script src="js/pupil.js?v=__V__" defer></script> <script src="js/pupil.js?v=__V__" defer></script>
<script src="js/pupil-live-actions.js?v=__V__" defer></script> <script src="js/pupil-live-actions.js?v=__V__" defer></script>

View file

@ -24,7 +24,7 @@
/* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden /* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden
over her-renders heen zoals adminTab hierboven. */ over her-renders heen zoals adminTab hierboven. */
let kmClass = null, kmSubject = "rekenen"; let kmClass = null, kmSubject = "rekenen";
const KM_SUBJECTS = ["taal", "rekenen", "world"]; const KM_SUBJECTS = ["taal", "rekenen", "world", "creatief"];
let stopDashboardLive=()=>{}; let stopDashboardLive=()=>{};
let liveDashboardState="reconnecting"; let liveDashboardState="reconnecting";

View file

@ -50,6 +50,7 @@ const REGISTRY = [
{ id:"birds", cat:"world", icon:"🐦", w:620, h:600, minW:360, minH:400, mount:mountBirds }, { id:"birds", cat:"world", icon:"🐦", w:620, h:600, minW:360, minH:400, mount:mountBirds },
{ id:"plants", cat:"world", icon:"🌼", w:620, h:600, minW:360, minH:400, mount:mountPlants }, { id:"plants", cat:"world", icon:"🌼", w:620, h:600, minW:360, minH:400, mount:mountPlants },
{ id:"topography", cat:"world", icon:"🗺️", w:680, h:640, minW:380, minH:440, mount:mountTopography }, { id:"topography", cat:"world", icon:"🗺️", w:680, h:640, minW:380, minH:440, mount:mountTopography },
{ id:"coloring", cat:"creatief", icon:"🎨", w:760, h:700, minW:390, minH:430, mount:mountColoring },
{ id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite },
{ id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind },
{ id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes },
@ -68,7 +69,7 @@ const REGISTRY = [
{ id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget }, { id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget },
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
]; ];
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["creatief","catCreative"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord. Op een /* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord. Op een
telefoon is de beschikbare ruimte leidend: een desktop-minimum van bv. telefoon is de beschikbare ruimte leidend: een desktop-minimum van bv.
@ -369,7 +370,7 @@ function makeWidget(def, saved){
? boardLearningMountState(def, state, boardLearning) : state; ? boardLearningMountState(def, state, boardLearning) : state;
mountedApi = def.mount(body, mountedState) || {}; mountedApi = def.mount(body, mountedState) || {};
if(typeof appendLearningProfileBadge === "function" && mountedState && if(typeof appendLearningProfileBadge === "function" && mountedState &&
["taal","rekenen"].includes(def.cat)){ ["taal","rekenen","creatief"].includes(def.cat)){
appendLearningProfileBadge(body, def.cat, mountedState, def.id); appendLearningProfileBadge(body, def.cat, mountedState, def.id);
} }
if(boardLearning && typeof appendBoardLearningLevelControls === "function"){ if(boardLearning && typeof appendBoardLearningLevelControls === "function"){

View file

@ -37,7 +37,7 @@ let castNotice = "";
let castActiveKeys = new Set(); let castActiveKeys = new Set();
let castError = ""; let castError = "";
const CAST_WIDGET_CATEGORIES=new Set(["taal","rekenen"]); const CAST_WIDGET_CATEGORIES=new Set(["taal","rekenen","creatief"]);
const CAST_THEME_WIDGETS=new Set(["letters","flits","hak","ball"]); const CAST_THEME_WIDGETS=new Set(["letters","flits","hak","ball"]);
function assignmentLibraryWidgets(){ function assignmentLibraryWidgets(){
return REGISTRY.filter(widget=>CAST_WIDGET_CATEGORIES.has(widget.cat)); return REGISTRY.filter(widget=>CAST_WIDGET_CATEGORIES.has(widget.cat));

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.64-beta"; const VERSION = "0.4.65-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -49,6 +49,12 @@ const I18N = {
wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.", wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.",
wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.", wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.",
wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.", wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.",
wg_coloring:"Kleurplaten", wg_coloring_d:"Kies thematische kleurplaten op leerjaar en niveau; kleur met een echt werkend kleurpotlood of vul een vak met de verfemmer.",
colorTheme:"Thema", colorPage:"Kleurplaat", colorProfile:"Groep {year} · niveau {level}",
colorThemeAll:"Alle thema's", colorThemeAnimals:"Dieren", colorThemeSeasons:"Seizoenen", colorThemeNature:"Natuur", colorThemeScience:"Wetenschap & techniek", colorThemeCulture:"Cultuur & omgeving",
colorToolPencil:"Kleurpotlood", colorToolBucket:"Verfemmer",
colorPencilHint:"Teken meerdere keren over dezelfde plek om de kleur donkerder te maken, net als met een echt kleurpotlood.", colorBucketHint:"Tik op een omsloten vak om het in één keer te kleuren.", colorEraserHint:"Veeg alleen de potloodlaag weg; ingekleurde vakken blijven staan.",
colorReset:"Kleurplaat leegmaken", colorDone:"Ik ben klaar", colorFinished:"Kleurplaat klaar!",
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen", bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes", bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
penSize:"Dikte", penColor:"Kleur", penExtras:"Hulpmiddelen", recentColors:"Recent gebruikt", paletteColors:"Kleuren", customColor:"Eigen kleur", eraser:"Gum", clearAll:"Alles wissen", penSize:"Dikte", penColor:"Kleur", penExtras:"Hulpmiddelen", recentColors:"Recent gebruikt", paletteColors:"Kleuren", customColor:"Eigen kleur", eraser:"Gum", clearAll:"Alles wissen",
@ -73,7 +79,7 @@ const I18N = {
anchorLibEmptyGrid:"Zet eerst woorden in het diagram.", anchorLibEmptyGrid:"Zet eerst woorden in het diagram.",
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —", numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit", muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
catTaal:"Taal", catRekenen:"Rekenen", catWorld:"Wereldoriëntatie", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media", catTaal:"Taal", catRekenen:"Rekenen", catWorld:"Wereldoriëntatie", catCreative:"Creatief", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media",
wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.", wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.",
wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.", wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.",
wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.", wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.",
@ -263,8 +269,8 @@ const I18N = {
wikiOfficialSource:"Open officiële SLO-publicatie", wikiOfficialSource:"Open officiële SLO-publicatie",
wikiOfficialArea:"Bekijk {area} in de officiële publicatie", wikiOfficialArea:"Bekijk {area} in de officiële publicatie",
wikiTuleTitle:"TULE en de nieuwe leerlijnen", wikiTuleTitle:"TULE en de nieuwe leerlijnen",
wikiYearProfiles:"Leerjaarprofielen taal en rekenen", wikiYearProfiles:"Leerjaarprofielen taal, rekenen en creatief",
wikiYearProfilesText:"Praktische Teach-uitwerking voor widgetopdrachten per groep. Gebaseerd op de kerndoelen 2026 en TULE; geen officiële SLO-jaarnormen.", wikiYearProfilesText:"Praktische Teach-uitwerking voor widgetopdrachten per groep. De creatieve profielen volgen de opbouw in detail en techniek; het zijn geen officiële jaarnormen.",
wikiYear:"Groep {n}", wikiProfileGoals:"Leerdoelen", wikiYear:"Groep {n}", wikiProfileGoals:"Leerdoelen",
wikiTuleText:"TULE werkt de kerndoelen uit 2006 voorbeeldmatig uit. Nieuwe SLO-leerlijnen bij de doelen van 2026 zijn nog in ontwikkeling. Een niveau-indeling in Teach is daarom geen officiële SLO-leerlijn.", wikiTuleText:"TULE werkt de kerndoelen uit 2006 voorbeeldmatig uit. Nieuwe SLO-leerlijnen bij de doelen van 2026 zijn nog in ontwikkeling. Een niveau-indeling in Teach is daarom geen officiële SLO-leerlijn.",
wikiTuleSource:"Open TULE bij SLO", wikiProgressionsSource:"Volg de ontwikkeling van nieuwe leerlijnen", wikiTuleSource:"Open TULE bij SLO", wikiProgressionsSource:"Volg de ontwikkeling van nieuwe leerlijnen",
@ -310,9 +316,9 @@ const I18N = {
vwPreview:"Voorbeeldweergave", vwPreviewReset:"Terug naar systeem", vwPreview:"Voorbeeldweergave", vwPreviewReset:"Terug naar systeem",
vwTeacherPath:"🎯 Opdrachtenreeks", vwTeacherDashboard:"📈 Klasdashboard", vwTeacherPath:"🎯 Opdrachtenreeks", vwTeacherDashboard:"📈 Klasdashboard",
vwParentPreview:"Voorbeeld van het ouderportaal. Echte kindgegevens en acties zijn in deze weergave uitgeschakeld.", vwParentPreview:"Voorbeeld van het ouderportaal. Echte kindgegevens en acties zijn in deze weergave uitgeschakeld.",
vwPupilPreview:"Voorbeeld van de leerlingomgeving met de taal- en rekenwidgets van het huidige bord. Bediening en voortgang zijn uitgeschakeld.", vwPupilPreview:"Voorbeeld van de leerlingomgeving met de taal-, reken- en creatieve widgets van het huidige bord. Bediening en voortgang zijn uitgeschakeld.",
vwPupilAddWidget:"Widget toevoegen", vwPupilChooseWidget:"Kies een leerlingwidget", vwPupilAddWidget:"Widget toevoegen", vwPupilChooseWidget:"Kies een leerlingwidget",
vwPupilAddWidgetNote:"Alleen taal- en rekenwidgets die in de leerlingomgeving werken worden getoond.", vwPupilAddWidgetNote:"Alleen taal-, reken- en creatieve widgets die in de leerlingomgeving werken worden getoond.",
vwNote:"Deze voorkeuren gelden voor dit apparaat.", vwNote:"Deze voorkeuren gelden voor dit apparaat.",
pwChanged:"Wachtwoord gewijzigd ✓", pwCurrentPh:"huidig wachtwoord", pwChanged:"Wachtwoord gewijzigd ✓", pwCurrentPh:"huidig wachtwoord",
namePh:"naam", passPh:"wachtwoord", namePh:"naam", passPh:"wachtwoord",
@ -342,7 +348,7 @@ const I18N = {
parentUnlink:"Ontkoppel", parentUnlink:"Ontkoppel",
parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.", parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.",
parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.", parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.",
parentLevel:"🎯 Leerniveau", parentLevelTaal:"Taal", parentLevelRekenen:"Rekenen", parentLevel:"🎯 Leerniveau", parentLevelTaal:"Taal", parentLevelRekenen:"Rekenen", parentLevelCreative:"Creatief",
parentLevelYear:"Groep", parentLevelPath:"Uitwerking", parentLevelSave:"Opslaan", parentLevelYear:"Groep", parentLevelPath:"Uitwerking", parentLevelSave:"Opslaan",
parentLevelSaved:"Leerniveau opgeslagen.", parentLevelSaved:"Leerniveau opgeslagen.",
dtLen:"Lengte", dtContrast:"Hoog contrast", dtLen:"Lengte", dtContrast:"Hoog contrast",
@ -409,7 +415,7 @@ const I18N = {
castClasses:"Klassen", castPupils:"Leerlingen", castClasses:"Klassen", castPupils:"Leerlingen",
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…", castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…",
castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Gebruik de pijlen om de volgorde te bepalen.", castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal-, reken- en creatieve widgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Gebruik de pijlen om de volgorde te bepalen.",
castIntro:"Stel zelfstandig een opdracht of reeks samen uit alle leerlingwidgets en geef deze daarna in één keer door.", castIntro:"Stel zelfstandig een opdracht of reeks samen uit alle leerlingwidgets en geef deze daarna in één keer door.",
castLibrarySubtitle:"Zelfstandige leerlingopdrachten · geen bord nodig", castLibrarySubtitle:"Zelfstandige leerlingopdrachten · geen bord nodig",
castStepChoose:"Kies vorm", castStepBuild:"Stel samen", castStepAudience:"Geef door", castStepChoose:"Kies vorm", castStepBuild:"Stel samen", castStepAudience:"Geef door",
@ -451,7 +457,7 @@ const I18N = {
amLevelClassDefault:"Klas-standaard", amLevelPupils:"Leerlingen", amLevelClassDefault:"Klas-standaard", amLevelPupils:"Leerlingen",
amLevelInherited:"erft van klas", amLevelNone:"nog niet ingesteld", amLevelInherited:"erft van klas", amLevelNone:"nog niet ingesteld",
amLevelReset:"terug naar klas-standaard", amLevelSaved:"Opgeslagen ✓", amLevelReset:"terug naar klas-standaard", amLevelSaved:"Opgeslagen ✓",
amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie", amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie", amSubject_creatief:"Creatief",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
amCopy:"kopieer", amCopyAll:"kopieer alles", amCopy:"kopieer", amCopyAll:"kopieer alles",
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
@ -473,6 +479,12 @@ const I18N = {
wg_notes:"Notes", wg_notes_d:"A sheet to write on.", wg_notes:"Notes", wg_notes_d:"A sheet to write on.",
wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.", wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.",
wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.", wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.",
wg_coloring:"Colouring pages", wg_coloring_d:"Choose themed colouring pages by year and level; use a realistic coloured pencil or fill an area with the paint bucket.",
colorTheme:"Theme", colorPage:"Colouring page", colorProfile:"Year {year} · level {level}",
colorThemeAll:"All themes", colorThemeAnimals:"Animals", colorThemeSeasons:"Seasons", colorThemeNature:"Nature", colorThemeScience:"Science & technology", colorThemeCulture:"Culture & surroundings",
colorToolPencil:"Coloured pencil", colorToolBucket:"Paint bucket",
colorPencilHint:"Draw over the same spot to make the colour darker, just like a real coloured pencil.", colorBucketHint:"Tap an enclosed area to colour it in one go.", colorEraserHint:"Erase only the pencil layer; filled areas stay in place.",
colorReset:"Clear colouring page", colorDone:"I'm finished", colorFinished:"Colouring page finished!",
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines", bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
bgGridS:"Small maths squares", bgGridB:"Big maths squares", bgGridS:"Small maths squares", bgGridB:"Big maths squares",
penSize:"Thickness", penColor:"Colour", penExtras:"Tools", recentColors:"Recently used", paletteColors:"Colours", customColor:"Custom colour", eraser:"Eraser", clearAll:"Clear all", penSize:"Thickness", penColor:"Colour", penExtras:"Tools", recentColors:"Recently used", paletteColors:"Colours", customColor:"Custom colour", eraser:"Eraser", clearAll:"Clear all",
@ -497,7 +509,7 @@ const I18N = {
anchorLibEmptyGrid:"Put words in the diagram first.", anchorLibEmptyGrid:"Put words in the diagram first.",
numWords:"Words per game", all:"All", noThemes:"— no themes yet —", numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off", muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
catTaal:"Language", catRekenen:"Maths", catWorld:"World studies", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media", catTaal:"Language", catRekenen:"Maths", catWorld:"World studies", catCreative:"Creative", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media",
wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.", wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.",
wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.", wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.",
wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 112.", wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 112.",
@ -687,8 +699,8 @@ const I18N = {
wikiOfficialSource:"Open the official SLO publication", wikiOfficialSource:"Open the official SLO publication",
wikiOfficialArea:"View {area} in the official publication", wikiOfficialArea:"View {area} in the official publication",
wikiTuleTitle:"TULE and the new learning progressions", wikiTuleTitle:"TULE and the new learning progressions",
wikiYearProfiles:"Language and maths year profiles", wikiYearProfiles:"Language, maths and creative year profiles",
wikiYearProfilesText:"Practical Teach interpretation for widget tasks per year. Based on the 2026 targets and TULE; not official SLO year standards.", wikiYearProfilesText:"Practical Teach interpretation for widget tasks per year. Creative profiles progress through detail and technique; these are not official year standards.",
wikiYear:"Year {n}", wikiProfileGoals:"Learning goals", wikiYear:"Year {n}", wikiProfileGoals:"Learning goals",
wikiTuleText:"TULE is an illustrative elaboration of the 2006 targets. New SLO learning progressions for the 2026 targets are still being developed. Teach levels are therefore not official SLO progressions.", wikiTuleText:"TULE is an illustrative elaboration of the 2006 targets. New SLO learning progressions for the 2026 targets are still being developed. Teach levels are therefore not official SLO progressions.",
wikiTuleSource:"Open TULE at SLO", wikiProgressionsSource:"Follow the development of new progressions", wikiTuleSource:"Open TULE at SLO", wikiProgressionsSource:"Follow the development of new progressions",
@ -734,9 +746,9 @@ const I18N = {
vwPreview:"Preview mode", vwPreviewReset:"Return to system", vwPreview:"Preview mode", vwPreviewReset:"Return to system",
vwTeacherPath:"🎯 Learning path", vwTeacherDashboard:"📈 Class dashboard", vwTeacherPath:"🎯 Learning path", vwTeacherDashboard:"📈 Class dashboard",
vwParentPreview:"Preview of the parent portal. Real child data and actions are disabled in this view.", vwParentPreview:"Preview of the parent portal. Real child data and actions are disabled in this view.",
vwPupilPreview:"Preview of the pupil environment using language and maths widgets from the current board. Controls and progress are disabled.", vwPupilPreview:"Preview of the pupil environment using language, maths and creative widgets from the current board. Controls and progress are disabled.",
vwPupilAddWidget:"Add widget", vwPupilChooseWidget:"Choose a pupil widget", vwPupilAddWidget:"Add widget", vwPupilChooseWidget:"Choose a pupil widget",
vwPupilAddWidgetNote:"Only language and maths widgets supported in the pupil environment are shown.", vwPupilAddWidgetNote:"Only language, maths and creative widgets supported in the pupil environment are shown.",
vwNote:"These preferences apply to this device.", vwNote:"These preferences apply to this device.",
pwChanged:"Password changed ✓", pwCurrentPh:"current password", pwChanged:"Password changed ✓", pwCurrentPh:"current password",
namePh:"name", passPh:"password", namePh:"name", passPh:"password",
@ -766,7 +778,7 @@ const I18N = {
parentUnlink:"Unlink", parentUnlink:"Unlink",
parentUnlinkWarn:"Removes only the link; your child's account remains.", parentUnlinkWarn:"Removes only the link; your child's account remains.",
parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.", parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.",
parentLevel:"🎯 Learning level", parentLevelTaal:"Language", parentLevelRekenen:"Maths", parentLevel:"🎯 Learning level", parentLevelTaal:"Language", parentLevelRekenen:"Maths", parentLevelCreative:"Creative",
parentLevelYear:"Year", parentLevelPath:"Path", parentLevelSave:"Save", parentLevelYear:"Year", parentLevelPath:"Path", parentLevelSave:"Save",
parentLevelSaved:"Learning level saved.", parentLevelSaved:"Learning level saved.",
dtLen:"Length", dtContrast:"High contrast", dtLen:"Length", dtContrast:"High contrast",
@ -833,7 +845,7 @@ const I18N = {
castClasses:"Classes", castPupils:"Pupils", castClasses:"Classes", castPupils:"Pupils",
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…", castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…",
castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.", castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language, maths and creative widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.",
castIntro:"Build an independent assignment or path from every pupil widget, then share it in one go.", castIntro:"Build an independent assignment or path from every pupil widget, then share it in one go.",
castLibrarySubtitle:"Independent pupil assignments · no board required", castLibrarySubtitle:"Independent pupil assignments · no board required",
castStepChoose:"Choose format", castStepBuild:"Build it", castStepAudience:"Assign it", castStepChoose:"Choose format", castStepBuild:"Build it", castStepAudience:"Assign it",
@ -875,7 +887,7 @@ const I18N = {
amLevelClassDefault:"Class default", amLevelPupils:"Pupils", amLevelClassDefault:"Class default", amLevelPupils:"Pupils",
amLevelInherited:"inherited from class", amLevelNone:"not set yet", amLevelInherited:"inherited from class", amLevelNone:"not set yet",
amLevelReset:"reset to class default", amLevelSaved:"Saved ✓", amLevelReset:"reset to class default", amLevelSaved:"Saved ✓",
amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation", amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation", amSubject_creatief:"Creative",
amConfirmDel:"Are you sure you want to delete this user?", amConfirmDel:"Are you sure you want to delete this user?",
amCopy:"copy", amCopyAll:"copy all", amCopy:"copy", amCopyAll:"copy all",
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",

View file

@ -91,8 +91,8 @@ const CURRICULUM_PO_2026=Object.freeze({
id:"kunst-en-cultuur",icon:"🎨",title:"Kunst en cultuur",goalsRange:"3537",page:92, id:"kunst-en-cultuur",icon:"🎨",title:"Kunst en cultuur",goalsRange:"3537",page:92,
description:"Artistiek creatief vermogen ontwikkelen, kunstzinnige uitingen maken en kunst en cultuur beleven en duiden.", description:"Artistiek creatief vermogen ontwikkelen, kunstzinnige uitingen maken en kunst en cultuur beleven en duiden.",
goals:[ goals:[
{id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media","birthday","avatar"]}, {id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media","birthday","avatar","coloring"]},
{id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media","birthday","avatar"]}, {id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media","birthday","avatar","coloring"]},
{id:37,domain:"Meemaken en betekenis geven",summary:"Leerlingen beleven kunst en cultuur actief en verbinden uitingen aan maker, tijd, plaats, functie en context.",parts:["Kunst en cultuur actief meemaken","Kunst en cultuur in context"],coverage:"open",widgets:[]} {id:37,domain:"Meemaken en betekenis geven",summary:"Leerlingen beleven kunst en cultuur actief en verbinden uitingen aan maker, tijd, plaats, functie en context.",parts:["Kunst en cultuur actief meemaken","Kunst en cultuur in context"],coverage:"open",widgets:[]}
] ]
}, },

View file

@ -1,4 +1,4 @@
/* teach - gedeelde leerjaarprofielen voor taal en rekenen /* teach - gedeelde leerjaarprofielen voor taal, rekenen en creatief
Praktische Teach-uitwerking van de nieuwe kerndoelen po (SLO, juli 2026) Praktische Teach-uitwerking van de nieuwe kerndoelen po (SLO, juli 2026)
en TULE. SLO ontwikkelt de nieuwe leerlijnen nog en kent ze geen wettelijke en TULE. SLO ontwikkelt de nieuwe leerlijnen nog en kent ze geen wettelijke
status toe; deze profielen zijn daarom geen officiele SLO-jaarnormen. */ status toe; deze profielen zijn daarom geen officiele SLO-jaarnormen. */
@ -31,6 +31,17 @@ const LEARNING_YEAR_PROFILES=Object.freeze({
lp("5-6","Grote getallen, kommagetallen en procenten",["standaardprocedures gebruiken","kommagetallen en breuken verbinden","eenvoudige procenten en verhoudingen berekenen"]), lp("5-6","Grote getallen, kommagetallen en procenten",["standaardprocedures gebruiken","kommagetallen en breuken verbinden","eenvoudige procenten en verhoudingen berekenen"]),
lp("7-8","Verhoudingen, procenten en meten",["breuken, kommagetallen en procenten verbinden","bewerkingen doelmatig kiezen","meetkundige problemen oplossen"]), lp("7-8","Verhoudingen, procenten en meten",["breuken, kommagetallen en procenten verbinden","bewerkingen doelmatig kiezen","meetkundige problemen oplossen"]),
lp("7-8","Redeneren en rekenen in samenhang",["meer-stapsproblemen modelleren","schatten en uitkomsten beoordelen","rekenwijzen helder uitleggen"]) lp("7-8","Redeneren en rekenen in samenhang",["meer-stapsproblemen modelleren","schatten en uitkomsten beoordelen","rekenwijzen helder uitleggen"])
]),
creatief:Object.freeze([
null,
lp("1-2","Grote vormen en vrij kleur ontdekken",["grote vlakken doelgericht kleuren","potloodgreep en druk verkennen","herkenbare thema's uit de leefwereld kiezen"]),
lp("1-2","Kleur en eenvoudige patronen",["binnen duidelijke contouren werken","kleuren combineren","eenvoudige herhaling herkennen"]),
lp("3-4","Vormen combineren en details zien",["middelgrote vlakken vullen","details met een potlood toevoegen","kleurkeuzes bij een thema uitleggen"]),
lp("3-4","Detail, herhaling en contrast",["kleinere vakken nauwkeurig kleuren","patronen voortzetten","licht-donkercontrast toepassen"]),
lp("5-6","Ruimte, natuur en verbeelding",["voor- en achtergrond onderscheiden","thematische kleuren onderzoeken","eigen details aan lijnwerk toevoegen"]),
lp("5-6","Kleurharmonie en complexe vormen",["kleurfamilies bewust combineren","complexere contouren beheersen","techniek en sfeer verbinden"]),
lp("7-8","Complexe patronen en betekenis",["veel detail planmatig uitwerken","kleurcontrast doelgericht inzetten","beeldkeuzes aan een thema koppelen"]),
lp("7-8","Zelfstandige stijl en verfijning",["een eigen kleurpalet ontwikkelen","gelaagd en nauwkeurig werken","artistieke keuzes toelichten"])
]) ])
}); });
const LANGUAGE_WIDGET_TASKS=Object.freeze({ const LANGUAGE_WIDGET_TASKS=Object.freeze({
@ -53,6 +64,9 @@ const MATH_WIDGET_TASKS=Object.freeze({
placevalue:["","hoeveelheid en cijfer","tientallen en eenheden","getallen tot 100","getallen tot 1.000","getallen tot 10.000","getallen tot 100.000","getallen tot 1.000.000","grote getalstructuur"], placevalue:["","hoeveelheid en cijfer","tientallen en eenheden","getallen tot 100","getallen tot 1.000","getallen tot 10.000","getallen tot 100.000","getallen tot 1.000.000","grote getalstructuur"],
money:["","munten herkennen","hele eurobedragen","eenvoudige bedragen","euros en centen","bedragen en teruggeven","precies rekenen met centen","korting en wisselgeld","budget en procentuele verandering"] money:["","munten herkennen","hele eurobedragen","eenvoudige bedragen","euros en centen","bedragen en teruggeven","precies rekenen met centen","korting en wisselgeld","budget en procentuele verandering"]
}); });
const CREATIVE_WIDGET_TASKS=Object.freeze({
coloring:["","grote kleurvlakken","duidelijke vormen en patronen","middelgrote vakken en details","meer detail en contrast","thematische composities","complexe contouren","fijne patronen en kleurplan","zelfstandige kleurstijl"]
});
function learningYearGroup(value){const year=Number(value);return Number.isInteger(year)&&year>=1&&year<=8?year:null} function learningYearGroup(value){const year=Number(value);return Number.isInteger(year)&&year>=1&&year<=8?year:null}
function learningLevel(value){return Math.max(1,Math.min(3,Number(value)||1))} function learningLevel(value){return Math.max(1,Math.min(3,Number(value)||1))}
@ -63,7 +77,8 @@ function learningProfile(subject,yearGroup,level){
function learningLevelLabel(level){return (LEARNING_LEVEL_LABELS[LANG]||LEARNING_LEVEL_LABELS.nl)[learningLevel(level)]} function learningLevelLabel(level){return (LEARNING_LEVEL_LABELS[LANG]||LEARNING_LEVEL_LABELS.nl)[learningLevel(level)]}
function learningProfileSummary(subject,yearGroup,level,widgetId){ function learningProfileSummary(subject,yearGroup,level,widgetId){
const p=learningProfile(subject,yearGroup,level);if(!p)return""; const p=learningProfile(subject,yearGroup,level);if(!p)return"";
const tasks=subject==="taal"?LANGUAGE_WIDGET_TASKS:MATH_WIDGET_TASKS,task=tasks[widgetId]&&tasks[widgetId][p.yearGroup]; const tasks=subject==="taal"?LANGUAGE_WIDGET_TASKS:subject==="rekenen"?MATH_WIDGET_TASKS:CREATIVE_WIDGET_TASKS;
const task=tasks[widgetId]&&tasks[widgetId][p.yearGroup];
return (LANG==="nl"?"Groep ":"Year ")+p.yearGroup+" | "+learningLevelLabel(p.level)+" | "+(task||p.title) return (LANG==="nl"?"Groep ":"Year ")+p.yearGroup+" | "+learningLevelLabel(p.level)+" | "+(task||p.title)
} }
function languageWordComplexity(word){ function languageWordComplexity(word){
@ -147,14 +162,15 @@ function mathTaskRules(yearGroup,level){
return{addSub:Math.max(10,Math.round(addCap*factor/5)*5),timesTable:year<3?2:year===3?(l===1?2:l===2?5:10):year===4?(l===1?5:10):12,factorMax:year<=3?5:year<=4?10:year<=6?12:20,numberlineMax:Math.max(20,Math.round(numberMax*(l===1?.4:l===2?.7:1))),numberlineMin:year>=7&&l>=2?-100:0,numberlineStep:lineStep,fractionDen:year===1?2:year<=3?4:year===4?8:12,clockLevel:year<=2?1:year<=4?2:3,clockMinutes:year===1?[0]:year===2?[0,30]:year===3?[0,15,30,45]:null,placeLevel:year<=2?1:year<=4?2:3,moneyLevel:year<=2?1:year<=4?2:3,percentSets:year<=5?[50,100]:year===6?[10,25,50,75,100]:year===7?[5,10,20,25,50,75,100]:[5,10,15,20,25,30,40,50,60,70,75,80,90,100]} return{addSub:Math.max(10,Math.round(addCap*factor/5)*5),timesTable:year<3?2:year===3?(l===1?2:l===2?5:10):year===4?(l===1?5:10):12,factorMax:year<=3?5:year<=4?10:year<=6?12:20,numberlineMax:Math.max(20,Math.round(numberMax*(l===1?.4:l===2?.7:1))),numberlineMin:year>=7&&l>=2?-100:0,numberlineStep:lineStep,fractionDen:year===1?2:year<=3?4:year===4?8:12,clockLevel:year<=2?1:year<=4?2:3,clockMinutes:year===1?[0]:year===2?[0,30]:year===3?[0,15,30,45]:null,placeLevel:year<=2?1:year<=4?2:3,moneyLevel:year<=2?1:year<=4?2:3,percentSets:year<=5?[50,100]:year===6?[10,25,50,75,100]:year===7?[5,10,20,25,50,75,100]:[5,10,15,20,25,30,40,50,60,70,75,80,90,100]}
} }
let SELF_LEARNING_LEVELS={taal:null,rekenen:null}; const LEARNING_SUBJECTS=["taal","rekenen","creatief"];
let SELF_LEARNING_LEVELS={taal:null,rekenen:null,creatief:null};
async function refreshSelfLearningLevels(){ async function refreshSelfLearningLevels(){
if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null){SELF_LEARNING_LEVELS={taal:null,rekenen:null};return SELF_LEARNING_LEVELS} if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null){SELF_LEARNING_LEVELS={taal:null,rekenen:null,creatief:null};return SELF_LEARNING_LEVELS}
const pairs=await Promise.all(["taal","rekenen"].map(async subject=>{try{return[subject,await api("/my/level?subject="+subject)]}catch(_){return[subject,null]}})); const pairs=await Promise.all(LEARNING_SUBJECTS.map(async subject=>{try{return[subject,await api("/my/level?subject="+subject)]}catch(_){return[subject,null]}}));
SELF_LEARNING_LEVELS=Object.fromEntries(pairs);return SELF_LEARNING_LEVELS SELF_LEARNING_LEVELS=Object.fromEntries(pairs);return SELF_LEARNING_LEVELS
} }
function applyLearningLevelToState(def,state,level,managed){ function applyLearningLevelToState(def,state,level,managed){
const next=state?Object.assign({},state):{};if(!def||!["taal","rekenen"].includes(def.cat)||!level||level.yearGroup==null)return next; const next=state?Object.assign({},state):{};if(!def||!LEARNING_SUBJECTS.includes(def.cat)||!level||level.yearGroup==null)return next;
next.yearGroup=learningYearGroup(level.yearGroup);next.level=learningLevel(level.level);next.managedLearningLevel=managed!==false;return next next.yearGroup=learningYearGroup(level.yearGroup);next.level=learningLevel(level.level);next.managedLearningLevel=managed!==false;return next
} }
function learningStateForWidget(def,state){ function learningStateForWidget(def,state){
@ -166,7 +182,7 @@ function learningStateForWidget(def,state){
in de systeem-, schoolbeheerder- en leerkrachtweergave. Ouder- en in de systeem-, schoolbeheerder- en leerkrachtweergave. Ouder- en
leerlingvoorbeelden gebruiken een aparte, alleen-lezen voorbeeldweergave. */ leerlingvoorbeelden gebruiken een aparte, alleen-lezen voorbeeldweergave. */
function canConfigureBoardLearningLevel(def){ function canConfigureBoardLearningLevel(def){
if(!def||!["taal","rekenen"].includes(def.cat)||!currentUser)return false; if(!def||!LEARNING_SUBJECTS.includes(def.cat)||!currentUser)return false;
const role=typeof activeRole==="function"?activeRole():currentUser.role; const role=typeof activeRole==="function"?activeRole():currentUser.role;
return currentUser.role==="teacher"|| return currentUser.role==="teacher"||
(currentUser.role==="super"&&["super","admin","teacher"].includes(role)) (currentUser.role==="super"&&["super","admin","teacher"].includes(role))
@ -203,10 +219,10 @@ function appendLearningProfileBadge(root,subject,state,widgetId){
(function registerOwnLearningLevelSettings(){ (function registerOwnLearningLevelSettings(){
const host=document.createElement("div");host.className="st-section";host.dataset.section="learning-level";host.style.display="none";host.id="learningLevelSettings";document.getElementById("stContent").appendChild(host); const host=document.createElement("div");host.className="st-section";host.dataset.section="learning-level";host.style.display="none";host.id="learningLevelSettings";document.getElementById("stContent").appendChild(host);
const text=()=>LANG==="nl"?{title:"Leerniveau",sub:"Kies zelf het passende leerjaar voor taal en rekenen.",note:"Deze keuze bepaalt woorden, getalgrenzen en opdrachten. De profielen zijn een Teach-uitwerking op basis van SLO/TULE; geen officiele SLO-jaarnormen.",save:"Opslaan",saved:"Leerniveau opgeslagen.",year:"Groep",taal:"Taal",rekenen:"Rekenen"}:{title:"Learning level",sub:"Choose the suitable year for language and maths.",note:"This choice determines words, number ranges and tasks. The profiles are a Teach interpretation based on SLO/TULE; not official SLO year standards.",save:"Save",saved:"Learning level saved.",year:"Year",taal:"Language",rekenen:"Maths"}; const text=()=>LANG==="nl"?{title:"Leerniveau",sub:"Kies het passende leerjaar voor taal, rekenen en creatief.",note:"Deze keuze bepaalt woorden, getalgrenzen, kleurplaatdetail en opdrachten. De profielen zijn een praktische Teach-uitwerking; geen officiele jaarnormen.",save:"Opslaan",saved:"Leerniveau opgeslagen.",year:"Groep",taal:"Taal",rekenen:"Rekenen",creatief:"Creatief"}:{title:"Learning level",sub:"Choose the suitable year for language, maths and creative work.",note:"This choice determines words, number ranges, colouring-page detail and tasks. The profiles are a practical Teach interpretation; not official year standards.",save:"Save",saved:"Learning level saved.",year:"Year",taal:"Language",rekenen:"Maths",creatief:"Creative"};
const render=async()=>{ const render=async()=>{
host.innerHTML="";const tx=text(),msg=document.createElement("div");msg.className="formMsg";host.appendChild(msg);await refreshSelfLearningLevels(); host.innerHTML="";const tx=text(),msg=document.createElement("div");msg.className="formMsg";host.appendChild(msg);await refreshSelfLearningLevels();
for(const subject of ["taal","rekenen"]){ for(const subject of LEARNING_SUBJECTS){
const current=SELF_LEARNING_LEVELS[subject]||{yearGroup:4,level:2},card=document.createElement("section");card.className="learning-level-card"; const current=SELF_LEARNING_LEVELS[subject]||{yearGroup:4,level:2},card=document.createElement("section");card.className="learning-level-card";
const h=document.createElement("h3");h.textContent=tx[subject];const summary=document.createElement("p");summary.className="guestnote";const controls=document.createElement("div");controls.className="am-inline"; const h=document.createElement("h3");h.textContent=tx[subject];const summary=document.createElement("p");summary.className="guestnote";const controls=document.createElement("div");controls.className="am-inline";
const year=document.createElement("select"),level=document.createElement("select");year.className=level.className="am-sel"; const year=document.createElement("select"),level=document.createElement("select");year.className=level.className="am-sel";
@ -217,5 +233,5 @@ function appendLearningProfileBadge(root,subject,state,widgetId){
} }
const note=document.createElement("p");note.className="guestnote learning-source-note";note.textContent=tx.note;host.appendChild(note) const note=document.createElement("p");note.className="guestnote learning-source-note";note.textContent=tx.note;host.appendChild(note)
}; };
registerSettingsSection({id:"learning-level",icon:"🎯",group:"personal",order:1.5,label:()=>text().title,sub:()=>text().sub,visible:()=>!!currentUser&&currentUser.role==="pupil"&&currentUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","groep","niveau"]}) registerSettingsSection({id:"learning-level",icon:"🎯",group:"personal",order:1.5,label:()=>text().title,sub:()=>text().sub,visible:()=>!!currentUser&&currentUser.role==="pupil"&&currentUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","creatief","groep","niveau"]})
})(); })();

View file

@ -324,7 +324,7 @@ function renderLiveClassOverview(host,dashboard,live,onPupil,onAction){
?REGISTRY.find(def=>def.id===pupil.session?.widgetType)?.cat:null; ?REGISTRY.find(def=>def.id===pupil.session?.widgetType)?.cat:null;
actionButton("↘ "+T("amActionEasier"),"easier", actionButton("↘ "+T("amActionEasier"),"easier",
()=>{if(window.confirm(T("amActionEasierConfirm")))trigger("easier",{subject});}, ()=>{if(window.confirm(T("amActionEasierConfirm")))trigger("easier",{subject});},
!pupil.session||!["taal","rekenen","world"].includes(subject)); !pupil.session||!["taal","rekenen","world","creatief"].includes(subject));
const pauseKind=pupil.session?.paused?"resume":"pause"; const pauseKind=pupil.session?.paused?"resume":"pause";
actionButton((pauseKind==="pause"?"⏸ ":"▶ ")+ actionButton((pauseKind==="pause"?"⏸ ":"▶ ")+
T(pauseKind==="pause"?"amActionPause":"amActionResume"),pauseKind, T(pauseKind==="pause"?"amActionPause":"amActionResume"),pauseKind,

View file

@ -106,10 +106,11 @@ function parentChildRow(c){
levelHolder.innerHTML = ""; levelHolder.innerHTML = "";
try{ try{
const data = await api(`/parent/children/${c.id}/levels`); const data = await api(`/parent/children/${c.id}/levels`);
["taal","rekenen"].forEach(subject=>{ ["taal","rekenen","creatief"].forEach(subject=>{
const current = data.levels?.[subject] || {yearGroup:4,level:2}; const current = data.levels?.[subject] || {yearGroup:4,level:2};
const card = prH("section","learning-level-card"); const card = prH("section","learning-level-card");
card.appendChild(prH("h3",null,T(subject==="taal"?"parentLevelTaal":"parentLevelRekenen"))); const subjectKey={taal:"parentLevelTaal",rekenen:"parentLevelRekenen",creatief:"parentLevelCreative"}[subject];
card.appendChild(prH("h3",null,T(subjectKey)));
const summary = prH("p","guestnote"); const summary = prH("p","guestnote");
const controls = prH("div","am-inline"); const controls = prH("div","am-inline");
const year = prH("select","am-sel"), level = prH("select","am-sel"); const year = prH("select","am-sel"), level = prH("select","am-sel");

View file

@ -7,7 +7,7 @@
voortgang wordt gelogd) of "kijken" (alleen meekijken: alle interactie voortgang wordt gelogd) of "kijken" (alleen meekijken: alle interactie
geblokkeerd via een overlay, snellere poll zodat wijzigingen van de geblokkeerd via een overlay, snellere poll zodat wijzigingen van de
leerkracht vlot doorkomen). */ leerkracht vlot doorkomen). */
const PUPIL_WIDGET_CATS = ["taal", "rekenen"]; const PUPIL_WIDGET_CATS = ["taal", "rekenen", "creatief"];
const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 }; const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 };
const PUPIL_LIVE_HEARTBEAT_MS = 12000; const PUPIL_LIVE_HEARTBEAT_MS = 12000;
const pupilView = document.getElementById("pupilView"); const pupilView = document.getElementById("pupilView");

View file

@ -0,0 +1,368 @@
/* teach - thematische kleurplaten met gekleurd potlood 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){
return `<${tag} data-fillable="true" data-part="${id}"${level>1?` data-level="${level}"`:""} ${attrs}/>`;
}
function coloringLine(tag,attrs="",level=1){
return `<${tag}${level>1?` data-level="${level}"`:""} ${attrs}/>`;
}
function coloringScene(content){
return `<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g class="coloring-svg-lines" fill="#fff" stroke="#26344a" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
${content}
</g>
</svg>`;
}
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 sceneButterfly(){return coloringScene(
coloringPart("rect","sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","grass",'d="M 4 430 Q 170 390 320 438 T 796 420 L 796 596 L 4 596 Z"')+
coloringPart("circle","sun",'cx="685" cy="105" r="62"')+
coloringPart("ellipse","wing-left-top",'cx="304" cy="248" rx="112" ry="135" transform="rotate(-28 304 248)"')+
coloringPart("ellipse","wing-right-top",'cx="496" cy="248" rx="112" ry="135" transform="rotate(28 496 248)"')+
coloringPart("ellipse","wing-left-bottom",'cx="323" cy="386" rx="92" ry="105" transform="rotate(22 323 386)"')+
coloringPart("ellipse","wing-right-bottom",'cx="477" cy="386" rx="92" ry="105" transform="rotate(-22 477 386)"')+
coloringPart("ellipse","body",'cx="400" cy="316" rx="35" ry="142"')+
coloringPart("circle","head",'cx="400" cy="155" r="42"')+
coloringLine("path",'d="M 380 122 Q 340 72 315 82 M 420 122 Q 460 72 485 82" fill="none"')+
[[293,220],[507,220],[325,382],[475,382]].map(([x,y],i)=>coloringPart("circle",`wing-dot-${i}`,`cx="${x}" cy="${y}" r="28"`,2)).join("")+
coloringLine("path",'d="M 385 155 Q 400 170 415 155" fill="none"',2)+
coloringPetals(125,475,"flower-a",2)+coloringPetals(665,492,"flower-b",3)
)}
function sceneAutumn(){return coloringScene(
coloringPart("rect","sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","ground",'d="M 4 440 Q 190 405 380 448 T 796 430 L 796 596 L 4 596 Z"')+
coloringPart("path","trunk",'d="M 337 500 Q 365 380 358 250 Q 398 285 420 250 Q 415 390 463 500 Z"')+
[[390,162,115],[285,215,95],[505,225,104],[340,290,92],[460,315,88]].map(([x,y,r],i)=>
coloringPart("circle",`crown-${i}`,`cx="${x}" cy="${y}" r="${r}"`,i>2?2:1)).join("")+
[[115,175,-18],[660,165,20],[175,355,12],[610,390,-24],[90,505,15],[705,500,-8]].map(([x,y,a],i)=>
coloringPart("path",`leaf-${i}`,`d="M ${x-35} ${y} Q ${x} ${y-45} ${x+35} ${y} Q ${x} ${y+45} ${x-35} ${y} Z" transform="rotate(${a} ${x} ${y})"`,2)).join("")+
coloringStars([[155,90,20],[585,95,18],[730,300,16]],"wind-leaf",3)+
coloringLine("path",'d="M 85 120 Q 170 80 240 118 M 560 120 Q 645 80 720 118" fill="none"',3)
)}
function sceneCake(){return coloringScene(
coloringPart("rect","party-wall",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","table",'d="M 4 445 L 796 445 L 796 596 L 4 596 Z"')+
coloringPart("rect","cake-bottom",'x="190" y="360" width="420" height="120" rx="24"')+
coloringPart("rect","cake-middle",'x="245" y="265" width="310" height="105" rx="22"')+
coloringPart("rect","cake-top",'x="305" y="180" width="190" height="95" rx="20"')+
coloringPart("path","icing-bottom",'d="M 190 390 Q 225 430 260 390 Q 300 430 340 390 Q 380 430 420 390 Q 460 430 500 390 Q 555 430 610 390 L 610 360 L 190 360 Z"',2)+
coloringPart("path","icing-middle",'d="M 245 295 Q 285 330 325 295 Q 365 330 405 295 Q 450 330 495 295 Q 525 325 555 295 L 555 265 L 245 265 Z"',2)+
coloringPart("rect","candle",'x="382" y="93" width="36" height="92" rx="8"')+
coloringPart("path","flame",'d="M 400 92 Q 355 55 400 20 Q 445 55 400 92 Z"')+
[[110,115],[690,120],[135,280],[665,275]].map(([x,y],i)=>coloringPart("path",`balloon-${i}`,`d="M ${x} ${y+55} C ${x-65} ${y+20} ${x-48} ${y-65} ${x} ${y-65} C ${x+48} ${y-65} ${x+65} ${y+20} ${x} ${y+55} Z"`,i>1?2:1)).join("")+
coloringLine("path",'d="M 110 170 Q 130 260 95 350 M 690 175 Q 670 260 705 350 M 135 335 Q 160 390 140 440 M 665 330 Q 640 390 660 440" fill="none"')+
coloringStars([[205,95,22],[595,72,22],[90,405,15],[720,410,15]],"confetti",3)
)}
function sceneOcean(){return coloringScene(
coloringPart("rect","water",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","sand",'d="M 4 475 Q 140 430 275 482 T 535 470 T 796 458 L 796 596 L 4 596 Z"')+
coloringPart("ellipse","fish-body",'cx="325" cy="260" rx="145" ry="88"')+
coloringPart("path","fish-tail",'d="M 185 260 L 85 178 Q 110 260 85 342 Z"')+
coloringPart("circle","fish-eye",'cx="395" cy="230" r="18"')+
coloringPart("path","turtle-shell",'d="M 510 390 Q 585 285 665 390 Q 600 485 510 390 Z"')+
coloringPart("circle","turtle-head",'cx="690" cy="382" r="38"')+
[[530,330,-28],[550,450,28],[635,330,25],[625,450,-25]].map(([x,y,a],i)=>coloringPart("ellipse",`flipper-${i}`,`cx="${x}" cy="${y}" rx="48" ry="20" transform="rotate(${a} ${x} ${y})"`,2)).join("")+
coloringLine("path",'d="M 270 220 Q 325 265 270 310 M 335 190 Q 370 260 335 330" fill="none"',2)+
coloringLine("path",'d="M 542 380 L 648 400 M 570 335 L 610 450 M 545 390 L 590 330 L 645 390 L 590 450 Z" fill="none"',3)+
[[115,105,18],[150,75,11],[640,100,22],[675,65,13],[730,145,15]].map(([x,y,r],i)=>coloringPart("circle",`bubble-${i}`,`cx="${x}" cy="${y}" r="${r}"`,3)).join("")+
coloringPart("path","seaweed-a",'d="M 120 525 Q 70 455 120 385 Q 165 455 120 525 Z"',2)+
coloringPart("path","seaweed-b",'d="M 190 535 Q 145 470 205 410 Q 240 475 190 535 Z"',3)
)}
function sceneGarden(){return coloringScene(
coloringPart("rect","garden-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","garden-ground",'d="M 4 390 Q 160 350 320 400 T 796 385 L 796 596 L 4 596 Z"')+
coloringPart("circle","sun",'cx="690" cy="95" r="58"')+
coloringPart("path","snail-shell",'d="M 120 405 C 120 290 300 290 305 405 C 280 490 155 490 120 405 Z"')+
coloringPart("path","snail-body",'d="M 95 455 Q 250 470 365 430 Q 410 415 438 450 Q 400 500 300 500 L 110 500 Z"')+
coloringPart("circle","snail-head",'cx="410" cy="410" r="48"')+
coloringLine("path",'d="M 388 370 L 370 315 M 427 370 L 450 318" fill="none"')+
coloringPart("circle","ladybird",'cx="600" cy="385" r="82"')+
coloringPart("circle","ladybird-head",'cx="600" cy="298" r="43"')+
coloringLine("path",'d="M 600 305 L 600 465 M 540 328 Q 600 385 540 440 M 660 328 Q 600 385 660 440" fill="none"',2)+
[[565,360],[635,360],[560,415],[640,415]].map(([x,y],i)=>coloringPart("circle",`ladybird-dot-${i}`,`cx="${x}" cy="${y}" r="15"`,2)).join("")+
coloringPetals(165,165,"garden-flower-a",2)+coloringPetals(430,145,"garden-flower-b",3)+
coloringLine("path",'d="M 165 205 L 165 315 M 430 185 L 430 280" fill="none"',2)
)}
function sceneWindmill(){return coloringScene(
coloringPart("rect","dutch-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","polder",'d="M 4 410 Q 190 380 365 420 T 796 405 L 796 596 L 4 596 Z"')+
coloringPart("path","water",'d="M 4 505 Q 200 475 400 515 T 796 500 L 796 596 L 4 596 Z"',2)+
coloringPart("path","mill-body",'d="M 310 470 L 350 185 L 485 185 L 535 470 Z"')+
coloringPart("path","mill-roof",'d="M 335 185 Q 420 78 500 185 Z"')+
coloringPart("rect","mill-door",'x="392" y="375" width="65" height="95" rx="25"')+
coloringPart("circle","mill-hub",'cx="418" cy="220" r="35"')+
[["M 405 190 L 320 55 L 345 40 L 430 205 Z"],["M 447 208 L 630 120 L 645 150 L 448 235 Z"],["M 430 250 L 515 410 L 485 425 L 405 248 Z"],["M 389 232 L 205 325 L 190 295 L 388 205 Z"]].map((d,i)=>coloringPart("path",`sail-${i}`,`d="${d}"`,1)).join("")+
coloringPart("rect","house",'x="80" y="345" width="165" height="125" rx="8"',2)+
coloringPart("path","house-roof",'d="M 55 345 L 165 260 L 270 345 Z"',2)+
coloringPart("rect","house-window-a",'x="105" y="375" width="45" height="45"',3)+
coloringPart("rect","house-window-b",'x="175" y="375" width="45" height="45"',3)+
coloringLine("path",'d="M 35 185 Q 75 155 115 185 Q 155 155 195 185 M 595 75 Q 630 50 665 75 Q 700 50 735 75" fill="none"',3)
)}
function sceneRocket(){return coloringScene(
coloringPart("rect","space",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("circle","planet-large",'cx="650" cy="150" r="100"')+
coloringPart("path","planet-ring",'d="M 535 120 Q 650 195 765 120 Q 650 255 535 120 Z"',2)+
coloringPart("path","rocket-body",'d="M 320 420 Q 315 185 400 75 Q 485 185 480 420 Z"')+
coloringPart("circle","rocket-window",'cx="400" cy="225" r="48"')+
coloringPart("path","rocket-left-fin",'d="M 325 335 Q 245 385 245 480 L 350 410 Z"')+
coloringPart("path","rocket-right-fin",'d="M 475 335 Q 555 385 555 480 L 450 410 Z"')+
coloringPart("path","flame-outer",'d="M 350 420 Q 330 525 400 575 Q 470 525 450 420 Z"')+
coloringPart("path","flame-inner",'d="M 380 420 Q 365 495 400 530 Q 435 495 420 420 Z"',2)+
coloringStars([[110,105,30],[210,225,22],[680,350,28],[120,430,20],[575,80,15]],"space-star",2)+
coloringPart("circle","moon",'cx="680" cy="475" r="62"',3)+
[[655,458,13],[700,490,17],[690,445,9]].map(([x,y,r],i)=>coloringPart("circle",`crater-${i}`,`cx="${x}" cy="${y}" r="${r}"`,3)).join("")
)}
function sceneRainforest(){return coloringScene(
coloringPart("rect","forest-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","forest-floor",'d="M 4 475 Q 190 430 360 490 T 796 455 L 796 596 L 4 596 Z"')+
coloringPart("path","tree-trunk-left",'d="M 40 500 Q 95 330 70 45 L 205 45 Q 165 330 235 500 Z"')+
coloringPart("path","tree-trunk-right",'d="M 585 500 Q 640 300 615 35 L 760 35 Q 720 300 785 500 Z"')+
coloringPart("path","vine",'d="M 170 55 Q 300 175 230 315 Q 170 430 300 485 Q 400 525 455 425 Q 505 330 430 265"',2)+
coloringPart("path","parrot-body",'d="M 340 270 Q 390 155 490 230 Q 520 330 430 405 Q 330 390 340 270 Z"')+
coloringPart("circle","parrot-head",'cx="450" cy="205" r="75"')+
coloringPart("path","parrot-beak",'d="M 505 205 Q 590 225 515 270 Z"')+
coloringPart("path","parrot-wing",'d="M 365 275 Q 455 245 465 365 Q 390 400 365 275 Z"',2)+
coloringPart("circle","parrot-eye",'cx="470" cy="185" r="14"')+
[[285,125,-25],[540,100,18],[265,405,15],[525,450,-20]].map(([x,y,a],i)=>coloringPart("path",`forest-leaf-${i}`,`d="M ${x-55} ${y} Q ${x} ${y-70} ${x+55} ${y} Q ${x} ${y+70} ${x-55} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i<2?2:3)).join("")+
coloringLine("path",'d="M 285 70 L 285 180 M 540 30 L 540 170 M 265 335 L 265 475 M 525 375 L 525 525" fill="none"',3)
)}
function sceneCastle(){return coloringScene(
coloringPart("rect","castle-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","castle-hill",'d="M 4 445 Q 200 380 400 450 T 796 420 L 796 596 L 4 596 Z"')+
coloringPart("rect","castle-main",'x="225" y="245" width="350" height="250"')+
coloringPart("rect","tower-left",'x="115" y="195" width="155" height="300"')+
coloringPart("rect","tower-right",'x="530" y="195" width="155" height="300"')+
coloringPart("path","roof-left",'d="M 85 195 L 193 75 L 300 195 Z"')+
coloringPart("path","roof-right",'d="M 500 195 L 608 75 L 715 195 Z"')+
coloringPart("path","gate",'d="M 350 495 L 350 385 Q 400 325 450 385 L 450 495 Z"')+
coloringPart("rect","flag-left",'x="193" y="55" width="92" height="50"',2)+
coloringPart("rect","flag-right",'x="608" y="55" width="92" height="50"',2)+
[[160,255],[225,255],[575,255],[640,255],[280,300],[475,300]].map(([x,y],i)=>coloringPart("rect",`window-${i}`,`x="${x-20}" y="${y-25}" width="40" height="58" rx="18"`,i>3?3:2)).join("")+
coloringLine("path",'d="M 225 345 L 575 345 M 155 385 L 230 385 M 570 385 L 645 385" fill="none"',3)+
coloringPart("path","path",'d="M 350 596 Q 365 510 400 445 Q 435 510 470 596 Z"',2)
)}
function sceneFourSeasons(){return coloringScene(
coloringPart("rect","season-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","season-ground",'d="M 4 455 Q 190 420 390 465 T 796 440 L 796 596 L 4 596 Z"')+
coloringPart("path","season-trunk",'d="M 345 510 Q 380 350 375 210 Q 420 255 445 205 Q 425 365 475 510 Z"')+
coloringPart("path","spring-crown",'d="M 385 230 Q 245 250 210 165 Q 225 65 355 85 Q 430 125 385 230 Z"')+
coloringPart("path","summer-crown",'d="M 395 230 Q 430 80 545 75 Q 675 95 620 220 Q 520 285 395 230 Z"')+
coloringPart("path","autumn-crown",'d="M 405 240 Q 560 220 635 295 Q 670 415 535 425 Q 420 390 405 240 Z"')+
coloringPart("path","winter-crown",'d="M 380 235 Q 335 385 225 420 Q 105 390 165 280 Q 250 210 380 235 Z"')+
coloringPetals(280,145,"spring-flower",2)+
coloringPart("circle","summer-fruit-a",'cx="515" cy="165" r="24"',2)+coloringPart("circle","summer-fruit-b",'cx="570" cy="215" r="24"',3)+
[[500,310,-15],[575,355,20],[460,375,-30]].map(([x,y,a],i)=>coloringPart("path",`autumn-leaf-${i}`,`d="M ${x-28} ${y} Q ${x} ${y-38} ${x+28} ${y} Q ${x} ${y+38} ${x-28} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i===2?3:2)).join("")+
coloringStars([[205,310,17],[285,345,15],[160,365,13]],"snowflake",3)
)}
function sceneEcoCity(){return coloringScene(
coloringPart("rect","city-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","city-ground",'d="M 4 455 L 796 455 L 796 596 L 4 596 Z"')+
coloringPart("rect","tower",'x="95" y="190" width="155" height="265" rx="8"')+
coloringPart("rect","school",'x="285" y="275" width="235" height="180" rx="8"')+
coloringPart("path","school-roof",'d="M 260 275 L 402 185 L 545 275 Z"')+
coloringPart("rect","apartments",'x="565" y="135" width="145" height="320" rx="8"')+
[[135,235],[205,235],[135,305],[205,305],[135,375],[205,375],[600,185],[670,185],[600,255],[670,255],[600,325],[670,325]].map(([x,y],i)=>coloringPart("rect",`city-window-${i}`,`x="${x-22}" y="${y-25}" width="44" height="50"`,i>5?2:1)).join("")+
coloringPart("rect","school-door",'x="375" y="365" width="55" height="90" rx="12"')+
coloringPart("circle","tree-crown",'cx="60" cy="410" r="55"',2)+coloringPart("rect","tree-trunk",'x="48" y="430" width="24" height="75"',2)+
coloringPart("path","bike-frame",'d="M 250 515 L 315 515 L 285 470 L 250 515 L 330 465"',3)+
coloringLine("circle",'cx="240" cy="520" r="34" fill="none"',3)+coloringLine("circle",'cx="330" cy="520" r="34" fill="none"',3)+
coloringPart("path","solar-a",'d="M 100 190 L 125 135 L 245 135 L 225 190 Z"',2)+coloringPart("path","solar-b",'d="M 580 135 L 600 80 L 705 80 L 690 135 Z"',2)+
coloringLine("path",'d="M 125 160 L 235 160 M 165 135 L 155 190 M 205 135 L 195 190 M 600 105 L 700 105 M 635 80 L 625 135 M 670 80 L 660 135" fill="none"',3)
)}
function sceneMandala(){
const rings=[];
for(let i=0;i<16;i++){
const angle=i*Math.PI/8,cx=400+Math.cos(angle)*155,cy=300+Math.sin(angle)*155;
rings.push(coloringPart("ellipse",`mandala-outer-${i}`,`cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="38" ry="82" transform="rotate(${i*22.5+90} ${cx.toFixed(1)} ${cy.toFixed(1)})"`,i%2?3:2));
}
for(let i=0;i<12;i++){
const angle=i*Math.PI/6,cx=400+Math.cos(angle)*92,cy=300+Math.sin(angle)*92;
rings.push(coloringPart("path",`mandala-inner-${i}`,`d="M 400 300 Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${(400+Math.cos(angle+.28)*145).toFixed(1)} ${(300+Math.sin(angle+.28)*145).toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} 400 300 Z"`,1));
}
return coloringScene(
coloringPart("rect","mandala-bg",'x="4" y="4" width="792" height="592" rx="28"')+
rings.join("")+coloringPart("circle","mandala-heart",'cx="400" cy="300" r="62"')+
coloringStars([[400,300,34]],"mandala-star",3)
);
}
function sceneEcosystem(){return coloringScene(
coloringPart("rect","pond-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","pond-bank",'d="M 4 390 Q 160 335 315 395 T 620 375 T 796 390 L 796 596 L 4 596 Z"')+
coloringPart("ellipse","pond-water",'cx="405" cy="485" rx="310" ry="90"')+
coloringPart("path","frog-body",'d="M 305 435 Q 345 350 410 430 Q 465 350 505 435 Q 460 500 405 470 Q 350 500 305 435 Z"')+
coloringPart("circle","frog-eye-a",'cx="350" cy="385" r="34"')+coloringPart("circle","frog-eye-b",'cx="460" cy="385" r="34"')+
coloringPart("path","lily-pad-a",'d="M 120 505 Q 205 430 280 510 Q 205 555 120 505 Z"',2)+
coloringPart("path","lily-pad-b",'d="M 525 515 Q 610 435 690 510 Q 610 560 525 515 Z"',2)+
coloringPart("path","dragonfly-body",'d="M 585 220 Q 600 300 585 365 Q 570 300 585 220 Z"',2)+
[[545,250,-25],[625,250,25],[550,310,25],[620,310,-25]].map(([x,y,a],i)=>coloringPart("ellipse",`dragonfly-wing-${i}`,`cx="${x}" cy="${y}" rx="55" ry="22" transform="rotate(${a} ${x} ${y})"`,2)).join("")+
coloringPart("circle","sun",'cx="115" cy="105" r="55"')+
coloringPart("path","reed-a",'d="M 105 440 Q 75 320 105 210 Q 135 320 105 440 Z"',3)+coloringPart("path","reed-b",'d="M 160 430 Q 130 300 165 185 Q 195 310 160 430 Z"',3)+
coloringLine("path",'d="M 105 310 L 65 270 M 160 325 L 205 270" fill="none"',3)
)}
const COLORING_PAGES=Object.freeze([
{id:"butterfly",theme:"animals",years:[1,2],title:{nl:"Blije vlinder",en:"Happy butterfly"},svg:sceneButterfly()},
{id:"autumn",theme:"seasons",years:[1,2],title:{nl:"Herfstboom",en:"Autumn tree"},svg:sceneAutumn()},
{id:"cake",theme:"culture",years:[1,2],title:{nl:"Feesttaart",en:"Party cake"},svg:sceneCake()},
{id:"ocean",theme:"animals",years:[3,4],title:{nl:"Vrienden in zee",en:"Ocean friends"},svg:sceneOcean()},
{id:"garden",theme:"nature",years:[3,4],title:{nl:"Kleine tuindieren",en:"Little garden animals"},svg:sceneGarden()},
{id:"windmill",theme:"culture",years:[3,4],title:{nl:"Molen in de polder",en:"Windmill in the polder"},svg:sceneWindmill()},
{id:"rocket",theme:"science",years:[5,6],title:{nl:"Raket door de ruimte",en:"Rocket through space"},svg:sceneRocket()},
{id:"rainforest",theme:"nature",years:[5,6],title:{nl:"Regenwoud",en:"Rainforest"},svg:sceneRainforest()},
{id:"castle",theme:"culture",years:[5,6],title:{nl:"Kasteel en dorp",en:"Castle and village"},svg:sceneCastle()},
{id:"four-seasons",theme:"seasons",years:[7,8],title:{nl:"Boom van vier seizoenen",en:"Four seasons tree"},svg:sceneFourSeasons()},
{id:"eco-city",theme:"science",years:[7,8],title:{nl:"Duurzame stad",en:"Sustainable city"},svg:sceneEcoCity()},
{id:"mandala",theme:"culture",years:[7,8],title:{nl:"Geometrische mandala",en:"Geometric mandala"},svg:sceneMandala()},
{id:"ecosystem",theme:"nature",years:[7,8],title:{nl:"Leven rond de vijver",en:"Life around the pond"},svg:sceneEcosystem()}
]);
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 coloringPencilWidth(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 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));
let tool=["pencil","bucket","eraser"].includes(initState&&initState.tool)?initState.tool:"pencil";
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></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"),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=[["pencil","✏️","colorToolPencil"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]];
TOOLS.forEach(([id,icon])=>{const button=document.createElement("button");button.type="button";button.dataset.tool=id;button.textContent=icon;button.onclick=()=>{tool=id;refreshTools()};toolHost.appendChild(button)});
COLORING_COLORS.forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-color";button.dataset.color=value;const dot=document.createElement("i");dot.style.background=value;button.appendChild(dot);button.onclick=()=>{color=value;custom.value=value;if(tool==="eraser")tool="pencil";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="pencil";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);
});
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);
TOOLS.forEach(([id,,key])=>{const button=toolHost.querySelector(`[data-tool="${id}"]`);button.title=T(key);button.setAttribute("aria-label",T(key))});
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":"colorPencilHint");
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":"colorPencilHint");
}
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();
});
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 pencilSegment(next,event){
const width=coloringPencilWidth(size,event.pressure,event.pointerType),erase=tool==="eraser";
ctx.save();ctx.globalCompositeOperation=erase?"destination-out":"source-over";ctx.strokeStyle=color;ctx.globalAlpha=erase?1:.27;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();
if(!erase){for(let n=0;n<2;n++){const jitter=(Math.random()-.5)*width*.65;ctx.globalAlpha=.07;ctx.lineWidth=Math.max(1,width*.28);ctx.beginPath();ctx.moveTo(last.x+jitter,last.y-jitter);ctx.lineTo(next.x-jitter,next.y+jitter);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;pencilSegment({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=>pencilSegment(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)};
}};
}

View file

@ -130,9 +130,10 @@
profiles.appendChild(node("summary",null,T("wikiYearProfiles"))); profiles.appendChild(node("summary",null,T("wikiYearProfiles")));
profiles.appendChild(node("p",null,T("wikiYearProfilesText"))); profiles.appendChild(node("p",null,T("wikiYearProfilesText")));
const profileGrid=node("div","wiki-profile-grid"); const profileGrid=node("div","wiki-profile-grid");
[["taal","📖"],["rekenen","🔢"]].forEach(([subject,icon])=>{ [["taal","📖"],["rekenen","🔢"],["creatief","🎨"]].forEach(([subject,icon])=>{
const column=node("section","wiki-profile-column"); const column=node("section","wiki-profile-column");
column.appendChild(node("h3",null,icon+" "+(subject==="taal"?T("parentLevelTaal"):T("parentLevelRekenen")))); const subjectKey={taal:"parentLevelTaal",rekenen:"parentLevelRekenen",creatief:"parentLevelCreative"}[subject];
column.appendChild(node("h3",null,icon+" "+T(subjectKey)));
LEARNING_YEAR_PROFILES[subject].slice(1).forEach((profile,index)=>{ LEARNING_YEAR_PROFILES[subject].slice(1).forEach((profile,index)=>{
const row=node("article","wiki-profile-row"); const row=node("article","wiki-profile-row");
row.appendChild(node("strong",null,T("wikiYear").replace("{n}",index+1))); row.appendChild(node("strong",null,T("wikiYear").replace("{n}",index+1)));

View file

@ -1167,12 +1167,12 @@ export default async function api(app) {
// ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied ------- // ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied -------
// Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven, // Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven,
// nu met vakgebied als extra dimensie (subject: taal/rekenen/world - dezelfde // nu met vakgebied als extra dimensie (subject: taal/rekenen/world/creatief - dezelfde
// waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de // waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de
// niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de // niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de
// leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is // leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is
// de fijnmazige verfijning daarbinnen die widgets nu al kennen. // de fijnmazige verfijning daarbinnen die widgets nu al kennen.
const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world']; const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world', 'creatief'];
const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) }); const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) });
const parseLevelBody = (req, reply) => { const parseLevelBody = (req, reply) => {
const { subject, yearGroup, level } = req.body ?? {}; const { subject, yearGroup, level } = req.body ?? {};
@ -1291,7 +1291,7 @@ export default async function api(app) {
if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd'); if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
const body = parseLevelBody(req, reply); const body = parseLevelBody(req, reply);
if (body.error) return body; if (body.error) return body;
if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied'); if (!['taal', 'rekenen', 'creatief'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
await pool.query( await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
@ -1525,7 +1525,7 @@ export default async function api(app) {
if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend'); if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend');
if (child.school_id != null) return { schoolManaged: true, levels: {} }; if (child.school_id != null) return { schoolManaged: true, levels: {} };
const rows = (await pool.query( const rows = (await pool.query(
"SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen')", "SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen','creatief')",
[child.id])).rows; [child.id])).rows;
return { return {
schoolManaged: false, schoolManaged: false,
@ -1541,7 +1541,7 @@ export default async function api(app) {
if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd'); if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
const body = parseLevelBody(req, reply); const body = parseLevelBody(req, reply);
if (body.error) return body; if (body.error) return body;
if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied'); if (!['taal', 'rekenen', 'creatief'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
await pool.query( await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL

View file

@ -5,7 +5,7 @@
const LIVE_WINDOW_MS = 45_000; const LIVE_WINDOW_MS = 45_000;
const HELP_IDLE_MS = 3 * 60_000; const HELP_IDLE_MS = 3 * 60_000;
const ACTION_KINDS = new Set(['hint', 'encourage', 'pause', 'resume', 'easier']); const ACTION_KINDS = new Set(['hint', 'encourage', 'pause', 'resume', 'easier']);
const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world']); const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world', 'creatief']);
export default function registerLiveClassroom(app, options) { export default function registerLiveClassroom(app, options) {
const { const {

96
test/coloring.test.js Normal file
View file

@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
async function coloringContext(){
const source=await readFile('public/js/widgets/coloring.js','utf8');
const context={};
runInNewContext(source+`;
globalThis.__coloring={
pages:COLORING_PAGES,
themes:COLORING_THEME_KEYS,
pagesFor:coloringPagesFor,
pencilWidth:coloringPencilWidth,
safeColor:coloringSafeColor
}`,context);
return{source,api:context.__coloring};
}
test('kleurplatenwidget laadt vóór het bord en is als creatieve leerlingwidget geregistreerd',async()=>{
const [html,board,pupil,cast,core,css]=await Promise.all([
readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'),readFile('public/js/cast.js','utf8'),
readFile('public/js/core.js','utf8'),readFile('public/css/coloring.css','utf8')
]);
assert.ok(html.indexOf('js/widgets/coloring.js')>0);
assert.ok(html.indexOf('js/widgets/coloring.js')<html.indexOf('js/board.js'));
assert.match(html,/css\/coloring\.css\?v=__V__/);
assert.match(board,/id:"coloring"[^\n]*cat:"creatief"[^\n]*mount:mountColoring/);
assert.match(pupil,/PUPIL_WIDGET_CATS\s*=\s*\[[^\]]*"creatief"/);
assert.match(cast,/CAST_WIDGET_CATEGORIES=new Set\(\[[^\]]*"creatief"/);
assert.match(css,/\.coloring-paper[^}]*aspect-ratio:4\/3/);
for(const key of ['wg_coloring','wg_coloring_d','catCreative','colorToolPencil','colorToolBucket','colorBucketHint','colorPencilHint']){
assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
}
});
test('elk leerjaar heeft meerdere passende kleurplaten met thema en drie detailniveaus',async()=>{
const {api}=await coloringContext();
const pages=JSON.parse(JSON.stringify(api.pages));
assert.equal(pages.length,13);
assert.equal(new Set(pages.map(page=>page.id)).size,pages.length);
for(let year=1;year<=8;year++){
const suitable=JSON.parse(JSON.stringify(api.pagesFor(year)));
assert.ok(suitable.length>=3,`groep ${year} heeft minimaal drie kleurplaten`);
assert.ok(suitable.every(page=>year>=page.years[0]&&year<=page.years[1]));
assert.ok(new Set(suitable.map(page=>page.theme)).size>=3,`groep ${year} heeft diverse thema's`);
}
for(const page of pages){
assert.ok(api.themes[page.theme],page.id+' heeft een bekend thema');
assert.ok(page.title.nl&&page.title.en,page.id+' heeft een tweetalige titel');
assert.match(page.svg,/data-fillable="true"/);
assert.match(page.svg,/data-level="2"/);
assert.match(page.svg,/data-level="3"/);
assert.doesNotMatch(page.svg,/<script|on\w+=/i);
}
assert.equal(api.pagesFor(5,'science').length,1);
assert.equal(api.pagesFor(3,'science').length,0);
});
test('potlood reageert op echte pendruk en bouwt kleur transparant op',async()=>{
const {source,api}=await coloringContext();
assert.ok(api.pencilWidth(7,.9,'pen')>api.pencilWidth(7,.2,'pen'));
assert.equal(api.pencilWidth(7,0,'mouse'),7);
assert.equal(api.safeColor('#AABBCC'),'#aabbcc');
assert.equal(api.safeColor('red'),'#e0554d');
for(const feature of ['getCoalescedEvents','event.pressure','erase?1:.27','quadraticCurveTo','Math.random()'])assert.ok(source.includes(feature),feature);
const css=await readFile('public/css/coloring.css','utf8');
assert.match(css,/mix-blend-mode:multiply/);
});
test('verfemmer vult een SVG-vak in één keer en state bewaart potlood én vlakkleuren',async()=>{
const {source}=await coloringContext();
assert.match(source,/event\.target\.closest\("\[data-fillable\]\[data-part\]\"\)/);
assert.match(source,/part\.setAttribute\("fill",color\)/);
assert.match(source,/fills\[part\.dataset\.part\]=color/);
assert.match(source,/canvas\.toDataURL\("image\/png"\)/);
assert.match(source,/return\{theme,pageId,fills:\{\.\.\.fills\},ink,tool,color,size,completed,yearGroup,level/);
assert.match(source,/opts\.onProgress/);
});
test('creatief niveau is beschikbaar per klas, leerling en thuiskind',async()=>{
const [api,admin,progressions,parent,wiki,migration,live]=await Promise.all([
readFile('src/api.js','utf8'),readFile('public/js/admin.js','utf8'),
readFile('public/js/learning-progressions.js','utf8'),readFile('public/js/parent.js','utf8'),
readFile('public/js/wiki.js','utf8'),
readFile('db/024_creative_pupil_levels.sql','utf8'),readFile('src/live-classroom.js','utf8')
]);
assert.match(api,/LEVEL_SUBJECTS = \[[^\]]*'creatief'/);
assert.match(admin,/KM_SUBJECTS = \[[^\]]*"creatief"/);
assert.match(progressions,/LEARNING_SUBJECTS=\[[^\]]*"creatief"/);
assert.match(parent,/\["taal","rekenen","creatief"\]\.forEach/);
assert.match(wiki,/creatief:"parentLevelCreative"/);
assert.match(migration,/CHECK \(subject IN \('taal','rekenen','world','creatief'\)\)/);
assert.match(live,/LEVEL_SUBJECTS = new Set\(\[[^\]]*'creatief'/);
});

View file

@ -17,7 +17,7 @@ async function progressionContext(){
return context; return context;
} }
test('taal en rekenen hebben een compleet inhoudelijk profiel voor groep 1 tot en met 8',async()=>{ test('taal, rekenen en creatief hebben een compleet inhoudelijk profiel voor groep 1 tot en met 8',async()=>{
const {__profiles:profiles}=await progressionContext(); const {__profiles:profiles}=await progressionContext();
for(const subject of ['taal','rekenen']){ for(const subject of ['taal','rekenen']){
assert.equal(profiles[subject].length,9); assert.equal(profiles[subject].length,9);

View file

@ -89,15 +89,15 @@ test('ouder leest en wijzigt het leerniveau van een gekoppeld thuiskind', async
const respond = (sql) => { const respond = (sql) => {
if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] }; if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] }; if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
if (sql.includes("subject IN ('taal','rekenen')")) return { rows: [ if (sql.includes("subject IN ('taal','rekenen','creatief')")) return { rows: [
{ subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 }, { subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 },{ subject:'creatief',year_group:5,level:3 },
] }; ] };
return undefined; return undefined;
}; };
const {app,calls}=await makeApp({user:parent,respond}); const {app,calls}=await makeApp({user:parent,respond});
const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies}); const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies});
assert.equal(get.statusCode,200,get.body); assert.equal(get.statusCode,200,get.body);
assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1}}); assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1},creatief:{yearGroup:5,level:3}});
const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies, const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies,
payload:{subject:'rekenen',yearGroup:5,level:3}}); payload:{subject:'rekenen',yearGroup:5,level:3}});
assert.equal(put.statusCode,200,put.body); assert.equal(put.statusCode,200,put.body);

View file

@ -49,12 +49,12 @@ test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', as
const { app, calls } = await makeApp(admin); const { app, calls } = await makeApp(admin);
const res = await app.inject({ const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies, method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'rekenen', yearGroup: 4, level: 2 }, payload: { subject: 'creatief', yearGroup: 4, level: 2 },
}); });
assert.equal(res.statusCode, 200, res.body); assert.equal(res.statusCode, 200, res.body);
const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels')); const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels'));
assert.ok(ins); assert.ok(ins);
assert.deepEqual(ins.params, [100, 'rekenen', 4, 2]); assert.deepEqual(ins.params, [100, 'creatief', 4, 2]);
}); });
test('klassenmanagement: ongeldig vakgebied geeft 400', async () => { test('klassenmanagement: ongeldig vakgebied geeft 400', async () => {
@ -123,17 +123,17 @@ test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen lee
assert.equal(res.statusCode, 403, res.body); assert.equal(res.statusCode, 403, res.body);
}); });
test('thuiskind kan eigen taalniveau opslaan, schoolleerling niet', async () => { test('thuiskind kan eigen creatief niveau opslaan, schoolleerling niet', async () => {
const { app, calls } = await makeApp(homePupil); const { app, calls } = await makeApp(homePupil);
const res = await app.inject({ method: 'PUT', url: '/api/my/level', cookies, const res = await app.inject({ method: 'PUT', url: '/api/my/level', cookies,
payload: { subject: 'taal', yearGroup: 5, level: 2 } }); payload: { subject: 'creatief', yearGroup: 5, level: 2 } });
assert.equal(res.statusCode, 200, res.body); assert.equal(res.statusCode, 200, res.body);
const insert = calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels')); const insert = calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels'));
assert.deepEqual(insert.params,[10,'taal',5,2]); assert.deepEqual(insert.params,[10,'creatief',5,2]);
const { app: schoolApp } = await makeApp(pupil); const { app: schoolApp } = await makeApp(pupil);
const denied = await schoolApp.inject({ method: 'PUT', url: '/api/my/level', cookies, const denied = await schoolApp.inject({ method: 'PUT', url: '/api/my/level', cookies,
payload: { subject: 'taal', yearGroup: 5, level: 2 } }); payload: { subject: 'creatief', yearGroup: 5, level: 2 } });
assert.equal(denied.statusCode,403,denied.body); assert.equal(denied.statusCode,403,denied.body);
await app.close();await schoolApp.close(); await app.close();await schoolApp.close();
}); });

View file

@ -324,11 +324,13 @@ test('letter- en cijferdoelvakken blijven gecentreerd en lange woorden blijven b
}); });
test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-bord',async()=>{ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-bord',async()=>{
const [board,pupil,css]=await Promise.all([ const [board,pupil,css,coloringCss]=await Promise.all([
readFile('public/js/board.js','utf8'), readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'), readFile('public/js/pupil.js','utf8'),
readFile('public/css/teach.css','utf8'), readFile('public/css/teach.css','utf8'),
readFile('public/css/coloring.css','utf8'),
]); ]);
const styles=css+coloringCss;
const registrySource=board.slice(board.indexOf('const REGISTRY'),board.indexOf('const WIDGET_CATS')); const registrySource=board.slice(board.indexOf('const REGISTRY'),board.indexOf('const WIDGET_CATS'));
const registered=[...registrySource.matchAll(/id:"([^"]+)"/g)].map(match=>match[1]).sort(); const registered=[...registrySource.matchAll(/id:"([^"]+)"/g)].map(match=>match[1]).sort();
const coverage={ const coverage={
@ -353,6 +355,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
placevalue:'.place-grid', placevalue:'.place-grid',
money:'.money-task-card', money:'.money-task-card',
wordtypes:'.wordtypes', wordtypes:'.wordtypes',
coloring:'.coloring-paper',
write:'.wp-bar', write:'.wp-bar',
mind:'.mm-top', mind:'.mm-top',
notes:'.notes{', notes:'.notes{',
@ -373,7 +376,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
}; };
assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit'); assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');
for(const [id,selector] of Object.entries(coverage)){ for(const [id,selector] of Object.entries(coverage)){
assert.ok(css.includes(selector),id+' mist responsive/breedte-onafhankelijke opmaak'); assert.ok(styles.includes(selector),id+' mist responsive/breedte-onafhankelijke opmaak');
} }
assert.match(css,/\.widget-body\{[^}]*container-type:inline-size[^}]*container-name:widget/); assert.match(css,/\.widget-body\{[^}]*container-type:inline-size[^}]*container-name:widget/);