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:
@@ -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{'&':'&','<':'<','>':'>','"':'"'}[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);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user