From 46696b0d7dc9c4fe423da16a5a17444e44965c09 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 10:19:19 +0200 Subject: [PATCH] refactor: personage-tekening volledig herbouwd als SVG (v0.4.26-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kernprobleem: de CSS-laagjesversie (losse divs met percentage-posities en z-index) had onderdelen die los van het lichaam hingen en een tekenvolgorde die niet klopte (soort-specifieke delen soms achter hoofd/lijf verstopt, soms er los voor zwevend). SVG lost dit structureel op: tekenvolgorde = laagvolgorde, geen z-index nodig, en elk onderdeel is een gebogen path/ellips die het silhouet van het vorige onderdeel ruim overlapt in plaats van ernaast te zweven. - Onderzocht hoe gangbare avatar-generators (DiceBear, Avataaars) dit aanpakken: vaste 100x100 viewBox, onderdelen als SVG-groepen in de juiste volgorde, kleur via directe fill-waarden i.p.v. CSS-variabelen in SVG. Dat patroon hier toegepast, geen code overgenomen. - Elke soort (dino/monster/dier) is nu een samenhangend lichaam: poten, armen, staart/oren, buikvlek en gezicht overlappen allemaal duidelijk met het hoofd/lijf. Dino: rugstekels, snuit met neusgaten, glimlach. Monster: hoorntjes, brede mond met slagtandjes, asymmetrische ogen. Dier: ronde oren, beer-snuit, kleine staart. - Haar (spits/krullen/paardenstaart/mohawk) en accessoires (pet/kroon/ bril/strik/sjaal/cape/badge) opnieuw getekend zodat ze ook echt op hun naam lijken, i.p.v. simpele gekleurde blokjes. - Regel toegevoegd: een pet of kroon dekt het hele bovenhoofd af, dus onderliggend haar wordt dan niet getekend (gaf anders rommelige, half verstopte plukjes rond de rand) - een paardenstaart blijft wel zichtbaar, want die hangt naar opzij. - Kleuren (lichter voor buik/snuit, donkerder voor poten/staart/ rugstekels) worden nu berekend uit de gekozen lichaamskleur zelf (avatarShade-helper), i.p.v. via CSS filter:brightness(). - CSS: alle oude .av-* laag-regels (~65 regels) verwijderd; alleen afmetingen (sm/md/lg) en de bestaande instellingen/widget/ aanmoedigings-CSS blijven staan. - Geverifieerd: alle vijf bestaande scenario's (opslaan, widget, School-navigator-badge met een echte klas vol verschillende personages, math.js-aanmoediging, ongeldige input), plus uitgebreide screenshot-rondes van losse onderdelen, alle soort/haar/accessoire- combinaties en alle drie weergavegroottes (24/44/180px) vóór het committen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/css/teach.css | 61 +------------- public/js/avatar.js | 193 ++++++++++++++++++++++++++++++++++++------- public/js/core.js | 2 +- 4 files changed, 166 insertions(+), 92 deletions(-) diff --git a/VERSION b/VERSION index 3e9b3af..196f403 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.25-beta \ No newline at end of file +0.4.26-beta \ No newline at end of file diff --git a/public/css/teach.css b/public/css/teach.css index f4ec74e..f381a60 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1674,69 +1674,10 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;} /* =========== Personage (avatar): gelaagde div's + CSS custom properties, zelfde aanpak als de taart hierboven (--cake/--icing), geen SVG/afbeeldingen. ==== */ -.avatar-face{position:relative; display:inline-block; flex:none;} +.avatar-face{display:inline-block; flex:none; overflow:visible;} .avatar-face.sm{width:24px; height:24px;} .avatar-face.md{width:44px; height:44px;} .avatar-face.lg{width:180px; height:180px;} -.av-body{position:absolute; left:20%; right:20%; bottom:2%; top:52%; border-radius:45% 45% 40% 40%; background:var(--av-body); z-index:1;} -.av-head{position:absolute; left:8%; right:8%; top:4%; height:56%; border-radius:50%; background:var(--av-body); z-index:3; box-shadow:inset 0 -6px 10px rgba(0,0,0,.12);} -.av-part-head{position:absolute; z-index:6; display:none;} -.av-species-monster .av-part-head{display:block; left:28%; right:28%; top:0; height:14%;} -.av-species-monster .av-part-head::before,.av-species-monster .av-part-head::after{content:""; position:absolute; width:16%; height:100%; background:var(--av-body); filter:brightness(.85); clip-path:polygon(50% 0,0% 100%,100% 100%);} -.av-species-monster .av-part-head::before{left:6%;} -.av-species-monster .av-part-head::after{right:6%;} -.av-species-animal .av-part-head{display:block; left:0; right:0; top:-2%; height:22%;} -.av-species-animal .av-part-head::before,.av-species-animal .av-part-head::after{content:""; position:absolute; width:22%; height:100%; border-radius:50%; background:var(--av-body);} -.av-species-animal .av-part-head::before{left:2%;} -.av-species-animal .av-part-head::after{right:2%;} -.av-part-body{position:absolute; z-index:6; display:none;} -.av-species-dino .av-part-body{display:block; left:22%; right:22%; top:36%; height:26%;} -.av-species-dino .av-part-body::before,.av-species-dino .av-part-body::after{content:""; position:absolute; width:22%; height:82%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} -.av-species-dino .av-part-body::before{left:4%;} -.av-species-dino .av-part-body::after{right:4%;} -.av-spike-mid{display:none; position:absolute;} -.av-species-dino .av-spike-mid{display:block; left:39%; width:22%; height:100%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} -.av-species-animal .av-part-body{display:block; right:2%; bottom:8%; width:16%; height:30%; border-radius:50% 50% 50% 10%; background:var(--av-body); transform:rotate(20deg); filter:brightness(.9);} -.av-tail{position:absolute; z-index:0; display:none;} -.av-species-dino .av-tail{display:block; left:-14%; bottom:10%; width:30%; height:16%; background:var(--av-body); filter:brightness(.93); clip-path:polygon(100% 15%,100% 85%,35% 100%,0% 55%,30% 0%); transform:rotate(-8deg);} -.av-leg{position:absolute; z-index:0; display:none; border-radius:45% 45% 55% 55%;} -.av-species-dino .av-leg{display:block; top:82%; height:16%; width:17%; background:var(--av-body); filter:brightness(.78);} -.av-leg-l{left:16%;} -.av-leg-r{right:16%;} -.av-arm{position:absolute; z-index:2; display:none; border-radius:50%;} -.av-species-dino .av-arm{display:block; top:66%; height:15%; width:13%; background:var(--av-body); filter:brightness(.88);} -.av-arm-l{left:9%; transform:rotate(20deg);} -.av-arm-r{right:9%; transform:rotate(-20deg);} -.av-belly{position:absolute; z-index:2; display:none; border-radius:50%;} -.av-species-dino .av-belly{display:block; left:35%; right:35%; top:66%; height:28%; background:var(--av-body); filter:brightness(1.4);} -.av-eye{position:absolute; z-index:4; top:30%; width:16%; height:16%; border-radius:50%; background:#fff; box-shadow:inset 0 0 0 1.5px rgba(20,20,30,.15);} -.av-eye::after{content:""; position:absolute; width:46%; height:46%; left:27%; top:30%; border-radius:50%; background:#232733;} -.av-eye-l{left:25%;} -.av-eye-r{right:25%;} -.av-snout{position:absolute; z-index:7; display:none;} -.av-species-dino .av-snout{display:block; left:30%; right:30%; top:46%; height:20%; border-radius:45%; background:var(--av-body); filter:brightness(1.22);} -.av-species-dino .av-snout::before,.av-species-dino .av-snout::after{content:""; position:absolute; width:14%; height:22%; top:28%; border-radius:50%; background:#2b2b2b; opacity:.6;} -.av-species-dino .av-snout::before{left:26%;} -.av-species-dino .av-snout::after{right:26%;} -.av-hair{position:absolute; z-index:4; display:none; background:var(--av-hair);} -.av-hair-spiky{display:block; left:14%; right:14%; top:-2%; height:22%; clip-path:polygon(0% 100%,10% 20%,25% 90%,35% 10%,50% 90%,65% 10%,75% 90%,90% 20%,100% 100%);} -.av-hair-curly{display:block; left:6%; right:6%; top:-4%; height:26%; border-radius:50% 50% 40% 40%;} -.av-hair-ponytail{display:block; left:10%; right:10%; top:-2%; height:20%; border-radius:50% 50% 30% 30%;} -.av-hair-ponytail::after{content:""; position:absolute; right:-6%; top:40%; width:20%; height:45%; border-radius:0 50% 50% 50%; background:var(--av-hair);} -.av-hair-mohawk{display:block; left:42%; right:42%; top:-8%; height:30%; border-radius:40% 40% 0 0;} -.av-hair-none{display:none;} -.av-acc-head{position:absolute; z-index:5; display:none;} -.av-acc-head-cap{display:block; left:6%; right:6%; top:2%; height:20%; border-radius:50% 50% 0 0; background:var(--blue);} -.av-acc-head-crown{display:block; left:14%; right:14%; top:-4%; height:18%; background:var(--yellow); clip-path:polygon(0% 100%,0% 40%,20% 70%,35% 10%,50% 60%,65% 10%,80% 70%,100% 40%,100% 100%);} -.av-acc-head-glasses{display:block; left:14%; right:14%; top:32%; height:16%;} -.av-acc-head-glasses::before,.av-acc-head-glasses::after{content:""; position:absolute; width:38%; height:100%; border-radius:50%; border:3px solid var(--ink); background:rgba(255,255,255,.35);} -.av-acc-head-glasses::before{left:0;} -.av-acc-head-glasses::after{right:0;} -.av-acc-head-bow{display:block; right:8%; top:8%; width:18%; height:14%; background:var(--pink); clip-path:polygon(0% 0%,50% 40%,100% 0%,100% 100%,50% 60%,0% 100%);} -.av-acc-body{position:absolute; z-index:4; display:none;} -.av-acc-body-scarf{display:block; left:18%; right:18%; top:48%; height:10%; border-radius:8px; background:var(--red);} -.av-acc-body-cape{display:block; left:10%; right:10%; top:54%; bottom:0; z-index:0; border-radius:0 0 40% 40%; background:var(--purple); opacity:.9;} -.av-acc-body-badge{display:block; left:32%; width:16%; top:62%; height:12%; border-radius:50%; background:var(--yellow); border:2px solid #fff;} .avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;} .avatar-preview{display:flex; justify-content:center; padding:10px; background:var(--surface-2); border-radius:16px; flex:none;} .avatar-controls{display:flex; flex-direction:column; gap:10px;} diff --git a/public/js/avatar.js b/public/js/avatar.js index 9ae2185..4483955 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -1,7 +1,9 @@ /* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar, accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het - avatar-widget. Gelaagde div's + CSS custom properties, zelfde aanpak als de - verjaardagstaart-widget (--cake/--icing) - geen SVG/afbeeldingen. */ + avatar-widget. Getekend als één SVG (viewBox 0 0 100 100, tekenvolgorde = + laagvolgorde - geen z-index-gepuzzel zoals bij de eerdere CSS-laagjesversie) + zodat elk onderdeel z'n vaste plek op het silhouet overlapt in plaats van + los ernaast te zweven. */ "use strict"; const AVATAR_SPECIES = ["dino", "monster", "animal"]; const AVATAR_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"]; @@ -11,6 +13,7 @@ const AVATAR_DEFAULT = Object.freeze({ species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", accessoryHead: "none", accessoryBody: "none", }); +const AVATAR_SVG_NS = "http://www.w3.org/2000/svg"; function avatarSanitize(config){ const c = config || {}; @@ -24,36 +27,166 @@ function avatarSanitize(config){ }; } -/* bouwt de gelaagde weergave van een personage; config==null → neutrale - standaardweergave (nooit een lege/kapotte weergave). */ +/* mengt een hex-kleur amt (-1..1) richting zwart (negatief) of wit (positief) - + gebruikt voor buik/snuit-highlights en pootjes/staart-schaduw, zodat die + altijd bij de gekozen lichaamskleur passen zonder een aparte kleurkiezer. */ +function avatarShade(hex, amt){ + const n = parseInt(hex.slice(1), 16); + let r = (n>>16)&255, g = (n>>8)&255, b = n&255; + if(amt < 0){ r *= 1+amt; g *= 1+amt; b *= 1+amt; } + else{ r += (255-r)*amt; g += (255-g)*amt; b += (255-b)*amt; } + const clamp = v => Math.max(0, Math.min(255, Math.round(v))); + return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join(""); +} + +/* per-soort silhouet: elk onderdeel overlapt het vorige ruim (poten/armen/ + staart tot ver ín het lijf, rugstekels/hoorns pas ná de kop getekend) zodat + niets los oogt. Volgorde = tekenvolgorde = zichtbare laagvolgorde. */ +function avatarBodyMarkup(species, bodyColor){ + const dark = avatarShade(bodyColor, -0.35); + const light = avatarShade(bodyColor, 0.4); + let m = ""; + if(species === "dino"){ + m += ``; + } + if(species === "animal"){ + m += ``; + } + m += ``; + m += ``; + m += ``; + m += ``; + m += ``; + m += ``; + if(species === "animal"){ + m += ``; + m += ``; + m += ``; + m += ``; + } + if(species === "monster"){ + const darker = avatarShade(bodyColor, -0.5); + m += ``; + m += ``; + } + m += ``; + if(species === "dino"){ + m += ``; + m += ``; + m += ``; + } + if(species === "animal"){ + m += ``; + m += ``; + } + if(species === "monster"){ + m += ``; + m += ``; + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + m += ``; + m += ``; + } + if(species === "dino"){ + m += ``; + const plate = avatarShade(bodyColor, -0.45); + m += ``; + m += ``; + m += ``; + } + if(species === "animal"){ + m += ``; + } + if(species === "monster"){ + m += ``; + m += ``; + m += ``; + } + return m; +} + +const AVATAR_HAIR_MARKUP = { + none: () => "", + spiky: (c) => ` + + + + + `, + curly: (c) => ` + + + + + `, + ponytail: (c) => ` + + + `, + mohawk: (c) => ``, +}; + +const AVATAR_HEAD_ACC_MARKUP = { + none: () => "", + cap: () => ` + + `, + crown: () => ` + + + + `, + glasses: () => ` + + + + + `, + bow: () => ` + + `, +}; + +const AVATAR_BODY_ACC_MARKUP = { + none: () => "", + scarf: () => ` + + `, + cape: () => ` + + `, + badge: () => ``, +}; + +/* een pet/kroon dekt het hele bovenhoofd af - loshangend haar eronder tonen + gaf rommelige, half-verstopte plukjes rond de rand (zie roadmap-memory). + Een paardenstaart mag wel blijven: die hangt naar opzij, niet over de kop. */ +function avatarHairSuppressed(c){ + return (c.accessoryHead === "cap" || c.accessoryHead === "crown") && c.hairStyle !== "ponytail"; +} + +/* bouwt de SVG-weergave van een personage; config==null → neutrale + standaardweergave (nooit een lege/kapotte weergave). Tekenvolgorde bepaalt + de laagvolgorde (SVG kent geen z-index) - vandaar cape/staart/poten eerst, + accessoires als laatste. */ function renderAvatarFace(config, size){ const c = avatarSanitize(config); - const face = document.createElement("div"); - face.className = "avatar-face " + (size || "md") + " av-species-" + c.species; - face.style.setProperty("--av-body", c.bodyColor); - face.style.setProperty("--av-hair", c.hairColor); - face.setAttribute("role", "img"); - face.setAttribute("aria-label", T("avatarAlt")); - const partHead = document.createElement("div"); partHead.className = "av-part-head"; - const hair = document.createElement("div"); hair.className = "av-hair av-hair-" + c.hairStyle; - const head = document.createElement("div"); head.className = "av-head"; - const eyeL = document.createElement("div"); eyeL.className = "av-eye av-eye-l"; - const eyeR = document.createElement("div"); eyeR.className = "av-eye av-eye-r"; - const snout = document.createElement("div"); snout.className = "av-snout"; - const accHead = document.createElement("div"); accHead.className = "av-acc-head av-acc-head-" + c.accessoryHead; - const tail = document.createElement("div"); tail.className = "av-tail"; - const legL = document.createElement("div"); legL.className = "av-leg av-leg-l"; - const legR = document.createElement("div"); legR.className = "av-leg av-leg-r"; - const partBody = document.createElement("div"); partBody.className = "av-part-body"; - const spikeMid = document.createElement("div"); spikeMid.className = "av-spike-mid"; - partBody.appendChild(spikeMid); - const body = document.createElement("div"); body.className = "av-body"; - const belly = document.createElement("div"); belly.className = "av-belly"; - const armL = document.createElement("div"); armL.className = "av-arm av-arm-l"; - const armR = document.createElement("div"); armR.className = "av-arm av-arm-r"; - const accBody = document.createElement("div"); accBody.className = "av-acc-body av-acc-body-" + c.accessoryBody; - face.append(tail, legL, legR, partBody, body, belly, armL, armR, accBody, partHead, head, eyeL, eyeR, snout, accHead, hair); - return face; + const svg = document.createElementNS(AVATAR_SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 100 100"); + svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", T("avatarAlt")); + let markup = ""; + if(c.accessoryBody === "cape") markup += AVATAR_BODY_ACC_MARKUP.cape(); + markup += avatarBodyMarkup(c.species, c.bodyColor); + if(!avatarHairSuppressed(c)) markup += AVATAR_HAIR_MARKUP[c.hairStyle](c.hairColor); + markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead](); + if(c.accessoryBody !== "cape") markup += AVATAR_BODY_ACC_MARKUP[c.accessoryBody](); + svg.innerHTML = markup; + return svg; } /* bouwt de interactieve kiezer + live voorbeeld; roept onChange(config) aan diff --git a/public/js/core.js b/public/js/core.js index 1ec1b4f..533ba80 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* 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. */ -const VERSION = "0.4.25-beta"; +const VERSION = "0.4.26-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION;