Lehrer-Drilldown: grafische Kennzahlen, Avatar-Bild, Sterne & Abzeichen

- sim-metrics.js: kuratiertes Feld-Schema pro Sim (Sterne/Prozentbalken/
  Verhältnis/Geld/Temperatur), technische Felder ausgeblendet, sauber benannt
- Drilldown zeigt Avatar als Bild (avatar:<slug>), Erfolg als Sterne,
  Parameter als grafische Kacheln, Abzeichen-Sammlung pro gespielter Sim
- live.php student_runs liefert player_progress fuer die Abzeichen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:35:29 +02:00
parent 2db99c37ee
commit c83da66325
3 changed files with 209 additions and 9 deletions
+158
View File
@@ -0,0 +1,158 @@
/* ============================================================================
* GeoGraSim — gemeinsames Sim-Kennzahlen-Schema
* ----------------------------------------------------------------------------
* Pro Simulation eine KURATIERTE, menschlich benannte Auswahl der wirklich
* relevanten Ergebnis-/Live-Felder mit Anzeigetyp (Sterne, Prozentbalken,
* Verhältnis, Geld, Temperatur, Zahl, Text, Ja/Nein). Alles NICHT Gelistete
* (technische Felder wie activeMs, phase, dishId …) wird ausgeblendet.
*
* renderSimMetrics(simId, params) → HTML-Grid mit grafischen Kacheln.
* Wiederverwendbar für Drilldown UND Ergebnis-Tabellen.
*
* Typen: stars | pct | pctInv | ratio(+t) | money | temp | num(+u) | text | bool | boolInv
* ==========================================================================*/
(function (root) {
'use strict';
var SIM_METRICS = {
klima: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'final_temperature',type:'temp',l:'Temperatur'},
{k:'final_flooded_pct',type:'pctInv',l:'Überflutung'},
{k:'final_co2',type:'num',l:'CO₂',u:'ppm'},
{k:'final_budget',type:'money',l:'Budget'},
{k:'final_population',type:'num',l:'Bevölkerung'},
{k:'measures_bought',type:'num',l:'Maßnahmen'}
],
heli: [
{k:'sterneGesamt',type:'stars',l:'Sterne'},
{k:'stars',type:'stars',l:'Sterne'},
{k:'planPct',type:'pct',l:'Planung'},
{k:'startScore',type:'starsSmall',l:'Start'},
{k:'landingScore',type:'starsSmall',l:'Landung'},
{k:'missionTitle',type:'text',l:'Mission'},
{k:'failed',type:'boolInv',l:'geschafft'}
],
busfahrt: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'wonOrders',type:'ratio',t:'totalOrders',l:'Aufträge gewonnen'},
{k:'quizFirstTry',type:'ratio',t:'totalOrders',l:'Quiz Erstversuch'},
{k:'avgTapKm',type:'num',l:'Ø Abweichung',u:'km'}
],
farmer: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'hit_rate',type:'pct',l:'Trefferquote'},
{k:'year',type:'num',l:'Jahr erreicht'},
{k:'crops_used',type:'num',l:'Sorten'}
],
sonnensystem: [
{k:'completed',type:'ratio',t:'total',l:'Aufgaben gelöst'},
{k:'firstTry',type:'bool',l:'Erstversuch'}
],
'eu-werkstatt': [
{k:'firstTryHits',type:'ratio',t:'stepsTotal',l:'Erste Wahl'},
{k:'muelleimerCount',type:'num',l:'verworfen'},
{k:'endType',type:'text',l:'Ergebnis'}
],
energiemanager: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'blocksBalanced',type:'ratio',t:'blocksTotal',l:'Blöcke ausbalanciert'},
{k:'daysPlayed',type:'ratio',t:'daysTotal',l:'Tage gespielt'}
],
weltkueche: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'completedDishes',type:'ratio',t:'totalDishes',l:'Gerichte'},
{k:'mistakesInDish',type:'num',l:'Fehlklicks'},
{k:'dishName',type:'text',l:'Aktuelles Gericht'},
{k:'ingSolved',type:'ratio',t:'ingTotal',l:'Zutaten gelöst'},
{k:'progressPct',type:'pct',l:'Fortschritt'}
],
logistik: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'ordersDelivered',type:'ratio',t:'totalOrders',l:'Ausgeliefert'},
{k:'ordersLate',type:'num',l:'verspätet'},
{k:'finalBalance',type:'money',l:'Bilanz'},
{k:'balance',type:'money',l:'Konto'}
],
fluss: [
{k:'stars',type:'stars',l:'Sterne'},
{k:'biodiv',type:'pct',l:'Biodiversität'},
{k:'economy',type:'pct',l:'Wirtschaft'},
{k:'flood',type:'pctInv',l:'Hochwasser'},
{k:'food',type:'pct',l:'Ernährung'}
],
kofferdetektiv: [
{k:'cases_solved',type:'ratio',t:'cases_total',l:'Fälle gelöst'},
{k:'correct_first_try',type:'num',l:'Erstversuch'},
{k:'wrong_guesses',type:'num',l:'Fehlversuche'}
],
tourismustal: [
{k:'goalsDone',type:'ratio',t:'goalsTotal',l:'Ziele erreicht'},
{k:'satisfaction',type:'pct',l:'Zufriedenheit'},
{k:'nature',type:'pct',l:'Naturwert'},
{k:'money',type:'money',l:'Konto'},
{k:'year',type:'num',l:'Jahr'},
{k:'buildings',type:'num',l:'Gebäude'},
{k:'maxGuests',type:'num',l:'Gäste (max)'}
],
tourismusregion: [
{k:'score',type:'pct',l:'Gesamtscore'},
{k:'goalsDone',type:'ratio',t:'goalsTotal',l:'Ziele erreicht'}
]
};
SIM_METRICS['klima-3d'] = SIM_METRICS.klima;
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function ampPct(v){ return v>=75?'ivm-g':v>=55?'ivm-li':v>=40?'ivm-y':v>=25?'ivm-o':'ivm-r'; }
function stars(n){
n = Math.max(0, Math.min(5, Math.round(n)));
return '<span class="ivm-stars">'+'★'.repeat(n)+'<span class="ivm-star-off">'+'★'.repeat(5-n)+'</span></span>';
}
function bar(pct, cls){
pct = Math.max(0, Math.min(100, Math.round(pct)));
return '<span class="ivm-bar"><i class="'+cls+'" style="width:'+pct+'%"></i></span>';
}
function money(v){
var n = Math.round(v), cls = n<0?'ivm-r':'ivm-g';
return '<span class="ivm-v '+cls+'">'+n.toLocaleString('de-AT')+' €</span>';
}
function num(v, u){ return '<span class="ivm-v">'+(typeof v==='number'?v.toLocaleString('de-AT'):esc(v))+(u?' <span class="ivm-u">'+u+'</span>':'')+'</span>'; }
function renderVal(f, v, src){
switch (f.type) {
case 'stars': return stars(v);
case 'starsSmall': return '<span class="ivm-stars sm">'+'★'.repeat(Math.max(0,Math.min(5,Math.round(v))))+'<span class="ivm-star-off">'+'★'.repeat(5-Math.max(0,Math.min(5,Math.round(v))))+'</span></span>';
case 'pct': return '<span class="ivm-v">'+Math.round(v)+'<span class="ivm-u">%</span></span>'+bar(v, ampPct(v));
case 'pctInv': return '<span class="ivm-v">'+Math.round(v)+'<span class="ivm-u">%</span></span>'+bar(v, ampPct(100-v));
case 'ratio': { var tot=src[f.t]; if(tot==null) return num(v); var pct=tot>0?(v/tot*100):0;
return '<span class="ivm-v">'+v+'<span class="ivm-u">/ '+tot+'</span></span>'+bar(pct, ampPct(pct)); }
case 'money': return money(v);
case 'temp': { var t=Math.round(v*10)/10, c=t<=2?'ivm-g':t<=3?'ivm-y':'ivm-r';
return '<span class="ivm-v '+c+'">'+t.toLocaleString('de-AT')+' °C</span>'; }
case 'bool': return v?'<span class="ivm-v ivm-g">✓ ja</span>':'<span class="ivm-v ivm-r">✗ nein</span>';
case 'boolInv': return v?'<span class="ivm-v ivm-r">✗ nein</span>':'<span class="ivm-v ivm-g">✓ ja</span>';
case 'text': return '<span class="ivm-v ivm-txt">'+esc(v)+'</span>';
default: return num(v, f.u);
}
}
function renderSimMetrics(simId, params){
if (!params || typeof params!=='object') return '';
var src = (params.results && typeof params.results==='object') ? params.results : params;
var schema = SIM_METRICS[simId] || [];
var seen = {}, out = [];
schema.forEach(function(f){
if (seen[f.l]) return; // Doppel-Label (z. B. stars/sterneGesamt) nur einmal
var v = src[f.k];
if (v==null || v==='') return;
seen[f.l] = 1;
out.push('<div class="ivm"><span class="ivm-l">'+esc(f.l)+'</span>'+renderVal(f, v, src)+'</div>');
});
return out.length ? '<div class="ivm-grid">'+out.join('')+'</div>'
: '<span style="font-size:.72rem;color:#8a8a8a">keine auswertbaren Parameter</span>';
}
root.SIM_METRICS = SIM_METRICS;
root.renderSimMetrics = renderSimMetrics;
root.ggsStarsHtml = stars;
})(typeof window !== 'undefined' ? window : this);
+7
View File
@@ -466,6 +466,10 @@ if ($method === 'GET') {
[$studentId]);
$active = $live && ((int)$live['last_seen_ts'] >= time() - LIVE_ACTIVE_WINDOW_SEC);
$progress = $db->fetchAll(
'SELECT sim_id, plays, best_stars, level FROM player_progress WHERE student_id = ?',
[$studentId]);
// Recency-Kennzahlen aus den Läufen
$recentScores = []; $daysSince = null;
foreach ($runs as $i => $r) {
@@ -480,6 +484,9 @@ if ($method === 'GET') {
'emoji' => $stu['emoji_avatar'] ?: '🧑‍🎓',
'recentErfolg' => $recentErfolg,
'daysSince' => $daysSince,
'progress' => array_map(function ($p) {
return ['simId'=>$p['sim_id'], 'plays'=>(int)$p['plays'], 'best_stars'=>(int)$p['best_stars'], 'level'=>(int)$p['level']];
}, $progress),
'live' => $active ? ['moduleId' => $live['module_id'], 'state' => json_decode($live['state_json'], true)] : null,
'runs' => array_map(function ($r) {
return [
+44 -9
View File
@@ -413,6 +413,7 @@ schueler01,Max M.,,,,,</pre>
</div>
<script src="assets/js/badges.js"></script>
<script src="assets/js/sim-metrics.js"></script>
<script>
var BASE = (window.__GGS__?.baseUrl) || '';
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
@@ -2137,8 +2138,15 @@ var IV_CSS = ''
+ '.iv-live{background:color-mix(in srgb,var(--ivg) 12%,transparent);border:1px solid color-mix(in srgb,var(--ivg) 30%,var(--ivline));border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:.82rem}'
+ '.iv-run{border:1px solid var(--ivline);border-radius:10px;padding:9px 12px;margin-bottom:8px}'
+ '.iv-run .rh{display:flex;align-items:center;gap:8px;font-size:.84rem;font-weight:700;margin-bottom:6px}.iv-run .rh .sc{font-weight:800}.iv-run .rh .d{margin-left:auto;font-size:.68rem;color:var(--ivmut);font-weight:400}'
+ '.iv-params{display:flex;flex-wrap:wrap;gap:5px}'
+ '.iv-pchip{display:inline-flex;gap:5px;align-items:baseline;background:var(--ivsoft);border-radius:6px;padding:2px 7px;font-size:.7rem}.iv-pchip .pk{color:var(--ivmut)}.iv-pchip .pv{font-weight:700}'
+ '.ivm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:7px}'
+ '.ivm{background:var(--ivsoft);border-radius:8px;padding:6px 9px;min-width:0}'
+ '.ivm-l{display:block;font-size:.58rem;color:var(--ivmut);text-transform:uppercase;letter-spacing:.03em;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
+ '.ivm-v{font-size:.92rem;font-weight:750}.ivm-u{font-size:.66rem;font-weight:600;color:var(--ivmut)}.ivm-txt{font-size:.8rem;font-weight:700}'
+ '.ivm-stars{color:#e0a838;font-size:.95rem;letter-spacing:1px}.ivm-stars.sm{font-size:.8rem}.ivm-star-off{color:var(--ivtrack)}'
+ '.ivm-bar{display:block;height:7px;border-radius:4px;background:var(--ivtrack);overflow:hidden;margin-top:3px}.ivm-bar>i{display:block;height:100%;border-radius:4px;background:currentColor}'
+ '.ivm-g{color:var(--ivg)}.ivm-li{color:var(--ivli)}.ivm-y{color:var(--ivy)}.ivm-o{color:var(--ivo)}.ivm-r{color:var(--ivr)}'
+ '.iv-badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}.iv-badge{width:38px;height:38px}.iv-badge img{width:100%;height:100%;object-fit:contain}.iv-badge.locked img{filter:grayscale(.9);opacity:.4}'
+ '.iv-mav{width:38px;height:38px;border-radius:50%;object-fit:cover}'
+ '@media (prefers-reduced-motion:reduce){.iv-dotA{animation:none!important}}';
function ivInjectCss(){ if(!document.getElementById('iv-css')){ var s=document.createElement('style'); s.id='iv-css'; s.textContent=IV_CSS; document.head.appendChild(s); } }
@@ -2302,6 +2310,15 @@ function ivParams(p){
});
return chips.length ? chips.join('') : '<span style="font-size:.7rem;color:var(--ivmut)">keine Parameter</span>';
}
function ivAvatar(emoji){
if (typeof emoji==='string' && emoji.indexOf('avatar:')===0){
var slug = emoji.slice(7).toLowerCase();
return '<img class="iv-mav" src="assets/img/avatars/thumbs/avatar-'+encodeURIComponent(slug)+'.webp" alt="" '
+ 'onerror="this.onerror=null;this.src=\'assets/img/avatars/thumbs/avatar-fuchs-explorer.webp\'">';
}
return '<span class="em" style="font-size:1.6rem">'+(emoji||'🧑‍🎓')+'</span>';
}
function ivStars(n){ return (typeof ggsStarsHtml==='function') ? ggsStarsHtml(n) : ('★'.repeat(Math.max(0,Math.min(5,Math.round(n)))) ); }
async function ivOpenStudent(sid){
ivInjectCss();
var d = await api('live?student_runs='+sid);
@@ -2310,20 +2327,38 @@ async function ivOpenStudent(sid){
ov.onclick = function(e){ if (e.target===ov) ov.remove(); };
document.addEventListener('keydown', function esc0(e){ if(e.key==='Escape'){ var o=document.getElementById('iv-ov'); if(o)o.remove(); document.removeEventListener('keydown', esc0);} });
if (!d || d.error){ ov.innerHTML='<div class="iv-modal">Konnte nicht laden.</div>'; document.body.appendChild(ov); return; }
var h='<div class="iv-modal"><div class="iv-mh"><span class="em">'+(d.emoji||'🧑‍🎓')+'</span><h3>'+esc(d.displayName)+'</h3>'
var sm = (typeof renderSimMetrics==='function') ? renderSimMetrics : function(){return '';};
var erfStars = (typeof ggsErfolgToStars==='function' && d.recentErfolg!=null) ? ggsErfolgToStars(d.recentErfolg) : null;
var h='<div class="iv-modal"><div class="iv-mh">'+ivAvatar(d.emoji)+'<h3>'+esc(d.displayName)+'</h3>'
+ '<button class="x" onclick="document.getElementById(\'iv-ov\').remove()">×</button></div>';
h+='<div class="iv-sum"><span>Erfolg (letzte Läufe)<b class="num">'+(d.recentErfolg!=null?d.recentErfolg:'')+'</b></span>'
+ '<span>Zuletzt aktiv<b>'+(d.daysSince==null?'nie':(d.daysSince<=0?'heute':(d.daysSince==1?'gestern':'vor '+d.daysSince+' T')))+'</b></span></div>';
h+='<div class="iv-sum">'
+ '<span>Erfolg (letzte Läufe) '+(erfStars!=null?'<b>'+ivStars(erfStars)+'</b> <span class="ivm-u">'+d.recentErfolg+'/100</span>':'<b></b>')+'</span>'
+ '<span>Zuletzt aktiv <b>'+(d.daysSince==null?'nie':(d.daysSince<=0?'heute':(d.daysSince==1?'gestern':'vor '+d.daysSince+' T')))+'</b></span></div>';
if (d.live){ var lm=ivModMeta(d.live.moduleId);
h+='<div class="iv-live"><b>Arbeitet gerade:</b> '+lm.icon+' '+esc(lm.title)+'<div class="iv-params" style="margin-top:7px">'+ivParams(d.live.state)+'</div></div>'; }
h+='<div style="font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--ivmut);margin-bottom:7px">Letzte Durchläufe · Simulationsparameter</div>';
h+='<div class="iv-live"><b>Arbeitet gerade:</b> '+lm.icon+' '+esc(lm.title)+'<div style="margin-top:8px">'+sm(d.live.moduleId, d.live.state)+'</div></div>'; }
// Abzeichen (aus player_progress, nur gespielte Sims)
if (typeof GGS_SIMS!=='undefined' && d.progress && d.progress.length){
var pmap={}; d.progress.forEach(function(p){ pmap[p.simId]=p; });
var anyBadge=false, bh='';
GGS_SIMS.forEach(function(sim){
var p=pmap[sim.id]; if(!p) return; anyBadge=true;
bh+='<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"><span style="font-size:.76rem;font-weight:700;width:118px;flex:none">'+sim.icon+' '+esc(sim.title)+'</span><div class="iv-badges">';
ggsSimBadges(sim.id).forEach(function(b){ var e=b.cond(p);
bh+='<span class="iv-badge'+(e?'':' locked')+'" title="'+esc(e?b.name:'Ziel: '+b.desc)+'"><img src="'+b.icon+'" alt="'+esc(b.name)+'"></span>'; });
bh+='</div></div>';
});
if(anyBadge) h+='<div style="font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--ivmut);margin:2px 0 7px">Abzeichen</div>'+bh;
}
h+='<div style="font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--ivmut);margin:8px 0 7px">Letzte Durchläufe</div>';
if (!d.runs || !d.runs.length){ h+='<p style="font-size:.8rem;color:var(--ivmut)">Noch keine Durchläufe.</p>'; }
(d.runs||[]).forEach(function(r){
var m=ivModMeta(r.moduleId);
var dt = r.completedAt ? new Date(r.completedAt.replace(' ','T')).toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit',year:'2-digit'}) : '';
h+='<div class="iv-run"><div class="rh">'+m.icon+' '+esc(m.title)+'<span class="sc">'+(r.score!=null?Math.round(r.score):'')+'</span>'
var rst=(typeof ggsErfolgToStars==='function'&&r.score!=null)?ggsErfolgToStars(r.score):null;
h+='<div class="iv-run"><div class="rh">'+m.icon+' '+esc(m.title)
+ '<span class="sc" style="color:#e0a838">'+(rst!=null?ivStars(rst):(r.score!=null?Math.round(r.score):''))+'</span>'
+ '<span class="d">'+dt+' · '+Math.round((r.durationSec||0)/60)+' min</span></div>'
+ '<div class="iv-params">'+ivParams(r.params)+'</div></div>';
+ sm(r.moduleId, r.params)+'</div>';
});
h+='</div>';
ov.innerHTML=h; document.body.appendChild(ov);