Modul-Klassenuebersicht: Ampelbalken pro Zelle + Verlauf als Liniengrafik
- renderModDetail: kleiner Ampelbalken je Kennzahl-Zelle, Werte spaltenweise normiert, Richtung aus h.agg (gruen=positiv, weniger-ist-besser invertiert) - Sterne-Zelle mit Ampelbalken (n/5) - Verlauf-Button zeigt statt JSON eine Liniengrafik ueber die wichtigsten Kennzahlen (eine Linie je Highlight-Spalte + Sterne, oben=besser, Legende mit letztem Wert + Trendpfeil); Snapshot-Kacheln bei nur einem Durchgang - sim-metrics.js: dir:'lo' fuer Kennzahlen wo weniger besser ist Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 @@
|
||||
: '<span style="font-size:.72rem;color:#8a8a8a">keine auswertbaren Parameter</span>';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
+141
-3
@@ -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 '<span style="display:block;height:5px;border-radius:3px;background:#ece8dc;margin-top:3px;overflow:hidden">'
|
||||
+ '<i style="display:block;height:100%;width:'+Math.round(g*100)+'%;background:'+col+'"></i></span>';
|
||||
}
|
||||
// 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 += '<table class="result-matrix" style="font-size:.72rem">';
|
||||
html += '<thead><tr>'
|
||||
+ '<th onclick="sortModDetail(\'name\')">Schüler*in '+arr('name')+'</th>'
|
||||
@@ -3579,7 +3603,7 @@ function renderModDetail() {
|
||||
: s.emoji;
|
||||
html += '<tr>'
|
||||
+ '<td>'+avatar+' '+esc(s.displayName)+'</td>'
|
||||
+ '<td style="color:#a37800">'+starStr+'</td>'
|
||||
+ '<td style="color:#a37800">'+starStr+(s.plays>0?ampBar(stars/5):'')+'</td>'
|
||||
+ '<td>'+s.plays+'</td>';
|
||||
hl.forEach(function(h) {
|
||||
var v = (s.highlights || {})[h.key];
|
||||
@@ -3587,7 +3611,7 @@ function renderModDetail() {
|
||||
var text = fmt(h, v);
|
||||
html += '<td'+(isChamp ? ' style="background:#fff5dc;font-weight:700"' : '')+'>'
|
||||
+ (isChamp && v !== null && v !== undefined ? '🏆 ' : '')
|
||||
+ text + '</td>';
|
||||
+ text + ampBar(hlGoodness(h, v)) + '</td>';
|
||||
});
|
||||
html += '<td>'+lastDate+'</td>'
|
||||
+ '<td>'+dur+'</td>'
|
||||
@@ -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 += '</div></div>';
|
||||
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 ? '' :
|
||||
'<div class="ivm" style="background:#f4f1e8;border-radius:8px;padding:6px 9px"><span style="display:block;font-size:.58rem;color:#8a8a8a;text-transform:uppercase">'+esc(m.label)+'</span><b style="font-size:.9rem">'+fmtMetric(m,v)+'</b></div>'; }).join('');
|
||||
return '<div style="margin-top:.5rem"><p style="font-size:.72rem;color:#8a8a8a;margin:.2rem 0 .5rem">Erst ein Durchgang – die Liniengrafik erscheint ab dem zweiten. Aktueller Stand:</p>'
|
||||
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:7px">'+chips+'</div></div>';
|
||||
}
|
||||
|
||||
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 '<p style="font-size:.72rem;color:#8a8a8a;margin-top:.5rem">Keine auswertbaren Verlaufswerte.</p>';
|
||||
|
||||
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 = '<svg viewBox="0 0 '+W+' '+H+'" width="100%" preserveAspectRatio="xMidYMid meet" style="display:block;font-family:inherit">';
|
||||
// horizontale Hilfslinien
|
||||
[0,0.25,0.5,0.75,1].forEach(function(g){
|
||||
var yy = padT + g*plotH;
|
||||
svg += '<line x1="'+padL+'" y1="'+yy+'" x2="'+(W-padR)+'" y2="'+yy+'" stroke="#ece8dc" stroke-width="1"/>';
|
||||
});
|
||||
svg += '<text x="'+padL+'" y="'+(padT-3)+'" font-size="9" fill="#8a9a7a" font-weight="700">▲ besser</text>';
|
||||
// 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 += '<text x="'+X(i)+'" y="'+(H-8)+'" font-size="9" fill="#9a9a9a" text-anchor="middle">'+lbl+'</text>';
|
||||
});
|
||||
// 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 += '<polyline points="'+seg.join(' ')+'" fill="none" stroke="'+s.color+'" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>'; seg = []; } return; }
|
||||
seg.push(X(i)+','+yy);
|
||||
pts += '<circle cx="'+X(i)+'" cy="'+yy+'" r="3" fill="'+s.color+'"/>';
|
||||
});
|
||||
if (seg.length) svg += '<polyline points="'+seg.join(' ')+'" fill="none" stroke="'+s.color+'" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||
svg += pts;
|
||||
});
|
||||
svg += '</svg>';
|
||||
|
||||
// Legende: Farbe · Name · letzter Wert · Trendpfeil
|
||||
var leg = '<div style="display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.5rem">';
|
||||
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 ? '<span style="color:#3f7a45">▲</span>' : worsened ? '<span style="color:#c65a49">▼</span>' : '<span style="color:#9a9a9a">▬</span>';
|
||||
var dirHint = s.hi ? '' : ' <span style="color:#9a9a9a;font-size:.62rem">(weniger = besser)</span>';
|
||||
leg += '<span style="display:inline-flex;align-items:center;gap:.3rem;font-size:.72rem">'
|
||||
+ '<span style="width:12px;height:3px;border-radius:2px;background:'+s.color+';display:inline-block"></span>'
|
||||
+ '<b>'+esc(s.m.label)+'</b> '+fmtMetric(s.m, s.last)+' '+trend+dirHint+'</span>';
|
||||
});
|
||||
leg += '</div>';
|
||||
|
||||
return '<div style="margin-top:.5rem;background:#fbfaf6;border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:.6rem .7rem">'
|
||||
+ '<div style="font-size:.72rem;color:#6a6a6a;margin-bottom:.3rem">Verlauf über '+n+' Durchgänge – jede Linie auf ihren eigenen Wertebereich normiert</div>'
|
||||
+ svg + leg + '</div>';
|
||||
}
|
||||
|
||||
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)) : '—';
|
||||
|
||||
Reference in New Issue
Block a user