diff --git a/App/assets/js/sim-metrics.js b/App/assets/js/sim-metrics.js index 71610c5..e03bbbe 100644 --- a/App/assets/js/sim-metrics.js +++ b/App/assets/js/sim-metrics.js @@ -19,7 +19,7 @@ {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_co2',type:'num',l:'CO₂',u:'ppm',dir:'lo'}, {k:'final_budget',type:'money',l:'Budget'}, {k:'final_population',type:'num',l:'Bevölkerung'}, {k:'measures_bought',type:'num',l:'Maßnahmen'} @@ -37,7 +37,7 @@ {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'} + {k:'avgTapKm',type:'num',l:'Ø Abweichung',u:'km',dir:'lo'} ], farmer: [ {k:'stars',type:'stars',l:'Sterne'}, @@ -51,7 +51,7 @@ ], 'eu-werkstatt': [ {k:'firstTryHits',type:'ratio',t:'stepsTotal',l:'Erste Wahl'}, - {k:'muelleimerCount',type:'num',l:'verworfen'}, + {k:'muelleimerCount',type:'num',l:'verworfen',dir:'lo'}, {k:'endType',type:'text',l:'Ergebnis'} ], energiemanager: [ @@ -62,7 +62,7 @@ weltkueche: [ {k:'stars',type:'stars',l:'Sterne'}, {k:'completedDishes',type:'ratio',t:'totalDishes',l:'Gerichte'}, - {k:'mistakesInDish',type:'num',l:'Fehlklicks'}, + {k:'mistakesInDish',type:'num',l:'Fehlklicks',dir:'lo'}, {k:'dishName',type:'text',l:'Aktuelles Gericht'}, {k:'ingSolved',type:'ratio',t:'ingTotal',l:'Zutaten gelöst'}, {k:'progressPct',type:'pct',l:'Fortschritt'} @@ -70,7 +70,7 @@ logistik: [ {k:'stars',type:'stars',l:'Sterne'}, {k:'ordersDelivered',type:'ratio',t:'totalOrders',l:'Ausgeliefert'}, - {k:'ordersLate',type:'num',l:'verspätet'}, + {k:'ordersLate',type:'num',l:'verspätet',dir:'lo'}, {k:'finalBalance',type:'money',l:'Bilanz'}, {k:'balance',type:'money',l:'Konto'} ], @@ -84,7 +84,7 @@ 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'} + {k:'wrong_guesses',type:'num',l:'Fehlversuche',dir:'lo'} ], tourismustal: [ {k:'goalsDone',type:'ratio',t:'goalsTotal',l:'Ziele erreicht'}, @@ -158,7 +158,26 @@ : 'keine auswertbaren Parameter'; } - root.SIM_METRICS = SIM_METRICS; - root.renderSimMetrics = renderSimMetrics; - root.ggsStarsHtml = stars; + // Plottbare (numerische) Kennzahlen einer Sim, dedupliziert nach Label. + var PLOTTABLE = {stars:1, starsSmall:1, pct:1, pctInv:1, ratio:1, temp:1, money:1, num:1}; + function simPlotMetrics(simId){ + var seen = {}, out = []; + (SIM_METRICS[simId] || []).forEach(function(f){ + if (!PLOTTABLE[f.type] || seen[f.l]) return; + seen[f.l] = 1; out.push(f); + }); + return out; + } + // true = höher ist besser (grün oben). temp/pctInv und dir:'lo' → niedriger ist besser. + function metricHigherBetter(f){ + if (f.dir === 'lo') return false; + if (f.dir === 'hi') return true; + return !(f.type === 'pctInv' || f.type === 'temp'); + } + + root.SIM_METRICS = SIM_METRICS; + root.renderSimMetrics = renderSimMetrics; + root.ggsStarsHtml = stars; + root.simPlotMetrics = simPlotMetrics; + root.metricHigherBetter = metricHigherBetter; })(typeof window !== 'undefined' ? window : this); diff --git a/App/teacher.html b/App/teacher.html index 3bc6f72..0104cc9 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -3556,6 +3556,30 @@ function renderModDetail() { return v + (h.unit ? ' ' + h.unit : ''); } + // Spalten-Spannweite je Kennzahl (für vergleichbare Balken) + var hlStats = {}; + hl.forEach(function(h) { + var vals = d.students.map(function(s){ var v=(s.highlights||{})[h.key]; return (v===null||v===undefined)?null:+v; }) + .filter(function(v){ return v!==null && !isNaN(v); }); + hlStats[h.key] = vals.length ? { min: Math.min.apply(null, vals), max: Math.max.apply(null, vals) } : null; + }); + // Ampelbalken: g = Güte 0..1 (1 = gut = grün). Grün immer positiv. + function ampBar(g) { + if (g === null) return ''; + g = Math.max(0, Math.min(1, g)); + var col = g>=.75 ? '#3f7a45' : g>=.55 ? '#7fa856' : g>=.4 ? '#e0a838' : g>=.25 ? '#e08a3a' : '#c65a49'; + return '' + + ''; + } + // Güte eines Werts in seiner Spalte, Richtung aus h.agg ('min' = weniger ist besser) + function hlGoodness(h, v) { + if (v === null || v === undefined || isNaN(+v)) return null; + var st = hlStats[h.key]; + if (!st || st.max === st.min) return 0.5; // keine Spreizung → neutral + var frac = (+v - st.min) / (st.max - st.min); + return h.agg === 'min' ? (1 - frac) : frac; + } + html += ''; html += '' + '' @@ -3579,7 +3603,7 @@ function renderModDetail() { : s.emoji; html += '' + '' - + '' + + '' + ''; hl.forEach(function(h) { var v = (s.highlights || {})[h.key]; @@ -3587,7 +3611,7 @@ function renderModDetail() { var text = fmt(h, v); html += '' + (isChamp && v !== null && v !== undefined ? '🏆 ' : '') - + text + ''; + + text + ampBar(hlGoodness(h, v)) + ''; }); html += '' + '' @@ -3624,10 +3648,14 @@ async function openStudentRuns(studentId) { } else if (_modDetail.moduleId === 'kofferdetektiv') { // Kofferdetektiv: aggregierte Auswertung + Heatmap der gelösten Länder html += renderKofferdetektivStudentAnalysis(r.runs); - } else { + } else if ({'eu-werkstatt':1,'energiemanager':1,'busfahrt':1,'fluggesellschaft':1,'heli':1}[_modDetail.moduleId]) { + // Module mit eigener, reicher Durchgangs-Karte r.runs.forEach(function(run, i) { html += renderRunDetailCard(_modDetail.moduleId, run, i); }); + } else { + // Alle übrigen: Liniengrafik über die wichtigsten Kennzahlen (statt JSON) + html += renderRunsLineChart(_modDetail.data.highlights || [], r.runs); } html += ''; box.innerHTML = html; @@ -3880,6 +3908,116 @@ function renderSonnensystemSessionTeacher(sess) { /* Sim-spezifischer Pretty-Renderer für einen einzelnen Run. Fallback (unbekannte Sim): raw JSON ausklappbar. */ +/* ──────────────────────────────────────────────────────────────────────── + * Verlaufs-Liniengrafik über die wichtigsten Kennzahlen einer Sim. + * Quelle = die Spalten-Definitionen (highlights: key/agg/unit/format) plus + * Sterne. Eine farbige Linie je Kennzahl, je Kennzahl auf ihre eigene + * Spannweite normiert (damit alle sichtbar), OBEN = besser (Richtung aus agg). + * Legende zeigt letzten Wert + Trendpfeil (grün besser / rot schlechter). + * ──────────────────────────────────────────────────────────────────────── */ +function _hlGet(obj, path){ + if (!obj) return null; + var parts = String(path).split('.'), v = obj; + for (var i = 0; i < parts.length; i++) { if (v == null) return null; v = v[parts[i]]; } + return v; +} +function renderRunsLineChart(highlights, runs) { + var rs = runs.slice().filter(function(r){ return r.submitted_at; }) + .sort(function(a,b){ return a.submitted_at < b.submitted_at ? -1 : a.submitted_at > b.submitted_at ? 1 : 0; }); + // Kennzahlen: Sterne (falls vorhanden) + alle Highlight-Spalten + var metrics = [{ key:'stars', label:'Sterne', hi:true, star:true }]; + (highlights || []).forEach(function(h){ + metrics.push({ key:h.key, label:h.label, unit:h.unit, format:h.format, hi:(h.agg !== 'min') }); + }); + function rawVal(run, m){ + var v = m.star ? _hlGet(run.results, 'stars') : _hlGet(run.results, m.key); + if (v === null || v === undefined || isNaN(+v)) return null; + return +v; + } + function fmtMetric(m, v){ + if (v === null) return '–'; + if (m.star) return Math.round(v) + '★'; + if (m.format === 'percent') return Math.round(v * 100) + ' %'; + var n = Math.abs(v) >= 100 ? Math.round(v) : Math.round(v * 10) / 10; + return n.toLocaleString('de-AT') + (m.unit ? ' ' + m.unit : ''); + } + + // Snapshot statt Linie bei nur einem Durchgang + if (rs.length < 2) { + var only = (rs[0] || runs[0] || {}).results || {}; + var chips = metrics.map(function(m){ var v = rawVal(rs[0]||runs[0]||{}, m); return v===null ? '' : + '
'+esc(m.label)+''+fmtMetric(m,v)+'
'; }).join(''); + return '

Erst ein Durchgang – die Liniengrafik erscheint ab dem zweiten. Aktueller Stand:

' + + '
'+chips+'
'; + } + + var COLORS = ['#3f7a45','#c65a49','#3a6a8a','#c9913a','#7a5a9a','#4a9a8a','#b0743a']; + var series = []; + metrics.forEach(function(m){ + var vals = rs.map(function(r){ return rawVal(r, m); }); + var present = vals.filter(function(v){ return v !== null; }); + if (present.length < 2) return; // ohne ≥2 Punkte keine Linie + var mn = Math.min.apply(null, present), mx = Math.max.apply(null, present); + series.push({ m:m, color:COLORS[series.length % COLORS.length], vals:vals, mn:mn, mx:mx, hi:m.hi, + first: present[0], last: present[present.length-1] }); + }); + if (!series.length) return '

Keine auswertbaren Verlaufswerte.

'; + + var W = 700, H = 210, padL = 10, padR = 10, padT = 12, padB = 30, n = rs.length; + var plotW = W - padL - padR, plotH = H - padT - padB; + function X(i){ return padL + (n > 1 ? i/(n-1) : 0.5) * plotW; } + function Y(s, v){ + if (v === null) return null; + var frac = (s.mx > s.mn) ? (v - s.mn)/(s.mx - s.mn) : 0.5; // 0..1 im eigenen Wertebereich + var good = s.hi ? frac : (1 - frac); // oben = besser + return padT + (1 - good) * plotH; + } + + var svg = ''; + // horizontale Hilfslinien + [0,0.25,0.5,0.75,1].forEach(function(g){ + var yy = padT + g*plotH; + svg += ''; + }); + svg += '▲ besser'; + // x-Achsen-Datumslabels + rs.forEach(function(r, i){ + var lbl = new Date(r.submitted_at.replace(' ','T')+'Z').toLocaleDateString('de-AT',{day:'2-digit',month:'2-digit'}); + var show = (n <= 8) || (i === 0) || (i === n-1) || (i % Math.ceil(n/8) === 0); + if (show) svg += ''+lbl+''; + }); + // Linien + Punkte je Kennzahl (Lücken bei null überspringen) + series.forEach(function(s){ + var seg = [], pts = ''; + s.vals.forEach(function(v, i){ + var yy = Y(s, v); + if (yy === null) { if (seg.length) { svg += ''; seg = []; } return; } + seg.push(X(i)+','+yy); + pts += ''; + }); + if (seg.length) svg += ''; + svg += pts; + }); + svg += ''; + + // Legende: Farbe · Name · letzter Wert · Trendpfeil + var leg = '
'; + series.forEach(function(s){ + var improved = s.hi ? (s.last > s.first) : (s.last < s.first); + var worsened = s.hi ? (s.last < s.first) : (s.last > s.first); + var trend = improved ? '' : worsened ? '' : ''; + var dirHint = s.hi ? '' : ' (weniger = besser)'; + leg += '' + + '' + + ''+esc(s.m.label)+' '+fmtMetric(s.m, s.last)+' '+trend+dirHint+''; + }); + leg += '
'; + + return '
' + + '
Verlauf über '+n+' Durchgänge – jede Linie auf ihren eigenen Wertebereich normiert
' + + svg + leg + '
'; +} + function renderRunDetailCard(simId, run, idx) { var dt = run.submitted_at ? new Date(run.submitted_at.replace(' ','T')+'Z').toLocaleString('de-AT', {day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'}) : '—'; var dur = run.duration_ms ? _fmtSeconds(Math.round(run.duration_ms/1000)) : '—';
Schüler*in '+arr('name')+'
'+avatar+' '+esc(s.displayName)+''+starStr+''+starStr+(s.plays>0?ampBar(stars/5):'')+''+s.plays+''+lastDate+''+dur+'