Files
geograsim/App/assets/js/live-client.js
T
Adminator 3d1b8e49a3 Übersicht-Feinschliff + Idle-Fairness
- Überschrift „Klassenübersicht", Sektion „Empfohlene Aktivität für dich."
- Volle Legende (Gradient-Balken, Ampelpunkte, Beispiel-Sparkline)
- 4b-Testdaten: jede Triage-Karte besetzt (Beteiligen/Begleiten/Fördern/Fordern/
  Bestätigen), Verlauf in den letzten 10 Wochen inkl. steigender Fälle
- live-client.js: Idle-Fenster 90s→5min (Lesen/Denken nicht bestrafen) +
  Tab-Sichtbarkeit blendet echtes Weg-Sein aus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:36:03 +02:00

213 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GeoGraSim Plattform: Live-Client
*
* Sendet Heartbeats an /api/live (Live-View für Lehrkräfte) und pollt das
* Pause-Flag aus /api/modules?student=1. Wird vom Sim-Wrapper als Plattform-
* Asset geladen — Sims selbst müssen nichts implementieren.
*
* Erwartet: window.__GGS__ = { sessionId, simId, baseUrl, basePath, apiUrl }.
*
* Sims können optional window.GGS_LIVE_STATE() zurückgeben, um einen kleinen
* State-Snapshot mitzusenden (max 50 KB). Ohne diese Funktion wird nur ein
* leerer State gemeldet — Lehrer sieht trotzdem WER online ist und WAS spielt.
*
* ──────────────────────────────────────────────────────────────────────────
* Plattform-Conventions, die Sims optional registrieren können:
*
* 1. window.GGS_LIVE_STATE = function() { return {…}; }
* Read-only Snapshot des aktuellen Sim-Zustands; wird in jeden Heartbeat
* gepackt und im Lehrer-Cockpit visualisiert.
*
* 2. window.GGS_TUTORIAL = { simId, title, replay: function() {…} }
* Onboarding-Replay-Hook. Der -Button im Header jeder Sim ruft
* GGS_TUTORIAL.replay() auf, damit Lernende die Anleitung jederzeit
* nochmal aufrufen können. Konvention für alle Sim-Header: ein
* <button class="ggs-btn-ghost" title="Anleitung nochmal zeigen"></button>
* in .ggs-header-actions, der das Replay triggert.
*
* 3. window.__GGS_LIVE__ (vom Plattform-Client gesetzt, read-only)
* Sims, die ihre eigene Spielzeit messen wollen, sollten activeMs/idleMs
* statt Date.now() - startTs lesen — sonst zählen Pausen/Idle mit.
* ──────────────────────────────────────────────────────────────────────────
*/
(function () {
'use strict';
if (window.__GGS_LIVE_INITIALIZED__) return;
window.__GGS_LIVE_INITIALIZED__ = true;
var ctx = window.__GGS__ || {};
// Ohne simId können wir nicht melden, welches Modul gespielt wird.
if (!ctx.simId) return;
// sessionId nicht vorab prüfen — der Browser sendet das ggs_session-Cookie
// automatisch mit den fetch-Calls (credentials:'same-origin'), und der
// Server entscheidet, ob es eine gültige Schüler-Session ist.
var apiBase = ctx.apiUrl || ((ctx.baseUrl || '') + '/php/api');
var simId = ctx.simId;
// --- Aktivitäts-/Idle-Tracking ---------------------------------------------
// Pause-Logik: aktive Spielzeit zählt NUR weiter, solange die letzte
// Nutzer:innen-Interaktion ≤ IDLE_THRESHOLD_MS her ist. Sonst gilt die
// Sitzung als „idle" und die Zeit-Akkumulation stoppt — verhindert
// absurd lange Sessions in der DB (Tab über Nacht offen, Wegfall ohne
// Tab-Schließung, etc.).
// Hard-Cap: 120 min pro Sim-Lauf. Frontend stoppt von selbst,
// Backend (assessment.php/progress.php) capped zusätzlich defensiv.
// Großzügig (5 min): Lesen und Nachdenken sollen NICHT als „weg" gelten.
// Scrollen/Mausbewegung zählen ohnehin als Interaktion (siehe EVENTS), und
// echtes Weg-Sein (anderer Tab) wird über die Tab-Sichtbarkeit ausgeblendet.
var IDLE_THRESHOLD_MS = 300000; // 5 min ohne jede Regung → idle
var MAX_SESSION_MS = 7200000; // 120 min — Plattform-Hardcap
var TICK_MS = 1000; // Auflösung der activeMs-Akkumulation
var sessionStartedAt = Date.now();
var lastInteractionAt = Date.now();
var activeMs = 0; // akkumuliert, NUR wenn nicht idle
var lastTickAt = Date.now();
var sessionCapped = false;
function onInteraction() {
lastInteractionAt = Date.now();
}
// Passive Listener, damit Scrollen/Touch nicht ausgebremst wird.
var EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel', 'click', 'pointerdown'];
EVENTS.forEach(function (ev) {
window.addEventListener(ev, onInteraction, { passive: true, capture: true });
});
// Tick-Loop für activeMs-Akkumulation. Läuft auch wenn Sim nichts tut,
// hält die Buchhaltung sauber. Inkrementiert in Echtzeit-Schritten, deckt
// damit auch große Lücken (Tab-Sleep) korrekt ab.
function tick() {
var now = Date.now();
var delta = now - lastTickAt;
lastTickAt = now;
// Tab nicht sichtbar (anderer Tab, minimiert) → echtes Weg-Sein, nicht zählen.
// Denkpausen bei SICHTBAREM Tab bleiben über das großzügige Idle-Fenster erhalten.
if (typeof document !== 'undefined' && document.hidden) return;
// Plausibilität: bei Tab-Sleep kann delta riesig sein → max einen Tick
// anrechnen, der Rest war definitiv nicht aktive Spielzeit.
if (delta > TICK_MS * 3) delta = 0;
var idleFor = now - lastInteractionAt;
if (idleFor < IDLE_THRESHOLD_MS) {
activeMs += delta;
if (activeMs >= MAX_SESSION_MS && !sessionCapped) {
activeMs = MAX_SESSION_MS;
sessionCapped = true;
// Sichtbarer Hinweis falls jemand 2h+ am Stück arbeitet
try { console.warn('[GGS] Session-Cap 120 min erreicht.'); } catch (_) {}
}
}
}
setInterval(tick, TICK_MS);
// Read-only Anbindung für Sims, die ihre eigene Dauer rechnen wollen.
// Sims sollten window.__GGS_LIVE__.activeMs lesen statt Date.now() - start.
window.__GGS_LIVE__ = Object.freeze({
get activeMs() { return activeMs; },
get idleMs() { return Date.now() - lastInteractionAt; },
get sessionStartedAt() { return sessionStartedAt; },
get lastInteractionAt() { return lastInteractionAt; },
get capped() { return sessionCapped; },
MAX_SESSION_MS: MAX_SESSION_MS,
IDLE_THRESHOLD_MS: IDLE_THRESHOLD_MS,
});
// --- Heartbeat ---
var heartbeatTimer = null;
function snapshotState() {
try {
if (typeof window.GGS_LIVE_STATE === 'function') {
var s = window.GGS_LIVE_STATE();
if (s && typeof s === 'object') return s;
}
} catch (_) {}
return {};
}
function sendHeartbeat() {
var state = snapshotState();
// Aktivitätsfelder generisch dazumischen — werden im Lehrer-Cockpit als
// status='idle' bzw. activeSeconds ausgewertet (ohne dass die Sim was tun muss).
state.__activeMs = activeMs;
state.__idleMs = Date.now() - lastInteractionAt;
state.__lastInteractionAt = lastInteractionAt;
state.__sessionStartedAt = sessionStartedAt;
state.__capped = sessionCapped;
fetch(apiBase + '/live.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ action: 'heartbeat', module_id: simId, state: state })
}).catch(function () {});
}
// --- Pause-Polling ---
var pauseTimer = null;
var pauseOverlay = null;
function ensureOverlay() {
if (pauseOverlay) return pauseOverlay;
var el = document.createElement('div');
el.id = 'ggs-pause-overlay';
el.style.cssText = 'position:fixed;inset:0;background:rgba(31,75,55,.78);color:#fff;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:Inter,sans-serif;font-weight:700;text-align:center;padding:2rem;backdrop-filter:blur(3px)';
el.innerHTML = '<div style="font-size:4rem;margin-bottom:.6rem">⏸</div>'
+ '<div style="font-size:1.6rem;margin-bottom:.4rem">Auftrag pausiert</div>'
+ '<div style="font-size:.95rem;font-weight:500;opacity:.85;max-width:420px;line-height:1.4">Deine Lehrperson hat das Tempo angehalten. Sobald sie freigibt, kannst du weitermachen.</div>';
document.body.appendChild(el);
pauseOverlay = el;
return el;
}
function showPause() { ensureOverlay().style.display = 'flex'; }
function hidePause() { if (pauseOverlay) pauseOverlay.style.display = 'none'; }
function checkPause() {
fetch(apiBase + '/modules.php?student=1&module_id=' + encodeURIComponent(simId), {
credentials: 'same-origin'
}).then(function (r) { return r.json(); })
.then(function (m) {
if (m && m.paused && m.mode === 'teacher_started') showPause();
else hidePause();
})
.catch(function () {});
}
// --- Spectator-Mode für Lehrkräfte ---
// ?view=teacher schaltet Input ab und blendet ein dezentes Banner ein.
function isSpectator() {
try { return new URLSearchParams(location.search).get('view') === 'teacher'; }
catch (_) { return false; }
}
function applySpectator() {
if (!isSpectator()) return;
var s = document.createElement('style');
s.textContent = 'html,body{pointer-events:none!important}'
+ '#ggs-spectator-banner{pointer-events:auto!important;position:fixed;top:0;left:0;right:0;background:#4a7c8a;color:#fff;padding:.5rem;text-align:center;font-family:Inter,sans-serif;font-size:.78rem;font-weight:700;z-index:99998}'
+ '#ggs-spectator-banner button{pointer-events:auto;margin-left:.6rem;padding:.2rem .6rem;border:none;border-radius:5px;background:#fff;color:#4a7c8a;font-weight:700;cursor:pointer}';
document.head.appendChild(s);
var bar = document.createElement('div');
bar.id = 'ggs-spectator-banner';
bar.innerHTML = '👁 Live-Ansicht (Lehrkraft) — Eingaben sind deaktiviert. <button onclick="window.close()">Fenster schließen</button>';
document.body.appendChild(bar);
}
// --- Lifecycle ---
function start() {
if (isSpectator()) { applySpectator(); return; } // Spectator nicht heartbeaten
sendHeartbeat();
heartbeatTimer = setInterval(sendHeartbeat, 4000);
checkPause();
pauseTimer = setInterval(checkPause, 5000);
// Bei Tab-Schließung sauberer „end"-Heartbeat (keepalive für unload)
window.addEventListener('beforeunload', function () {
try {
navigator.sendBeacon && navigator.sendBeacon(
apiBase + '/live.php',
new Blob([JSON.stringify({ action: 'end' })], { type: 'application/json' })
);
} catch (_) {}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();