| <!doctype html>
|
| <html lang="es">
|
| <head>
|
| <meta charset="UTF-8" />
|
| <meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| <title>PAMPAr Classroom — Replay</title>
|
| <style>
|
| :root {
|
| --bg: #0d1117;
|
| --surface: #161b22;
|
| --border: #30363d;
|
| --text: #e6edf3;
|
| --text2: #8b949e;
|
| --accent: #58a6ff;
|
| --green: #3fb950;
|
| --red: #f85149;
|
| --orange: #d29922;
|
| --purple: #bc8cff;
|
| }
|
| * {
|
| margin: 0;
|
| padding: 0;
|
| box-sizing: border-box;
|
| }
|
| body {
|
| font-family:
|
| "Segoe UI",
|
| system-ui,
|
| -apple-system,
|
| sans-serif;
|
| background: var(--bg);
|
| color: var(--text);
|
| height: 100vh;
|
| display: flex;
|
| flex-direction: column;
|
| overflow: hidden;
|
| }
|
|
|
|
|
| header {
|
| background: var(--surface);
|
| border-bottom: 1px solid var(--border);
|
| padding: 12px 20px;
|
| display: flex;
|
| align-items: center;
|
| gap: 16px;
|
| flex-shrink: 0;
|
| }
|
| header h1 {
|
| font-size: 16px;
|
| font-weight: 600;
|
| display: flex;
|
| align-items: center;
|
| gap: 8px;
|
| }
|
| header h1 .icon {
|
| font-size: 20px;
|
| }
|
| .meta-badge {
|
| background: var(--bg);
|
| border: 1px solid var(--border);
|
| border-radius: 12px;
|
| padding: 2px 10px;
|
| font-size: 12px;
|
| color: var(--text2);
|
| }
|
| .meta-badge b {
|
| color: var(--text);
|
| }
|
|
|
|
|
| .main {
|
| flex: 1;
|
| display: flex;
|
| overflow: hidden;
|
| }
|
|
|
|
|
| .chat-area {
|
| flex: 1;
|
| display: flex;
|
| flex-direction: column;
|
| overflow: hidden;
|
| }
|
| .chat-scroll {
|
| flex: 1;
|
| overflow-y: auto;
|
| padding: 16px 20px;
|
| }
|
| .chat-scroll::-webkit-scrollbar {
|
| width: 6px;
|
| }
|
| .chat-scroll::-webkit-scrollbar-thumb {
|
| background: var(--border);
|
| border-radius: 3px;
|
| }
|
|
|
|
|
| .msg {
|
| margin-bottom: 12px;
|
| padding: 10px 14px;
|
| border-radius: 8px;
|
| max-width: 85%;
|
| font-size: 14px;
|
| line-height: 1.5;
|
| animation: fadeSlide 0.3s ease;
|
| position: relative;
|
| }
|
| @keyframes fadeSlide {
|
| from {
|
| opacity: 0;
|
| transform: translateY(8px);
|
| }
|
| to {
|
| opacity: 1;
|
| transform: translateY(0);
|
| }
|
| }
|
| .msg .ts {
|
| font-size: 10px;
|
| color: var(--text2);
|
| position: absolute;
|
| top: 4px;
|
| right: 8px;
|
| }
|
| .msg-system {
|
| background: rgba(88, 166, 255, 0.08);
|
| border-left: 3px solid var(--accent);
|
| color: var(--accent);
|
| max-width: 100%;
|
| font-size: 13px;
|
| }
|
| .msg-lesson-start {
|
| background: var(--surface);
|
| border: 1px solid var(--border);
|
| max-width: 100%;
|
| margin-top: 20px;
|
| }
|
| .msg-lesson-start .badge {
|
| display: inline-block;
|
| background: var(--accent);
|
| color: var(--bg);
|
| border-radius: 4px;
|
| padding: 1px 8px;
|
| font-size: 11px;
|
| font-weight: 700;
|
| margin-right: 8px;
|
| }
|
| .msg-student {
|
| background: rgba(63, 185, 80, 0.08);
|
| border-left: 3px solid var(--green);
|
| margin-left: 20px;
|
| }
|
| .msg-student::before {
|
| content: "🧑🎓 Alumno";
|
| display: block;
|
| font-size: 11px;
|
| font-weight: 700;
|
| color: var(--green);
|
| margin-bottom: 4px;
|
| }
|
| .msg-teacher {
|
| border-left: 3px solid var(--purple);
|
| margin-left: 20px;
|
| }
|
| .msg-teacher.correct {
|
| background: rgba(63, 185, 80, 0.06);
|
| }
|
| .msg-teacher.incorrect {
|
| background: rgba(248, 81, 73, 0.06);
|
| }
|
| .msg-teacher::before {
|
| content: "👨🏫 Profesor";
|
| display: block;
|
| font-size: 11px;
|
| font-weight: 700;
|
| color: var(--purple);
|
| margin-bottom: 4px;
|
| }
|
| .msg-solution {
|
| background: rgba(188, 140, 255, 0.06);
|
| border-left: 3px solid var(--purple);
|
| margin-left: 40px;
|
| font-size: 13px;
|
| }
|
| .msg-solution::before {
|
| content: "💡 Solución correcta";
|
| display: block;
|
| font-size: 11px;
|
| font-weight: 700;
|
| color: var(--purple);
|
| margin-bottom: 4px;
|
| }
|
| .msg-stats {
|
| background: var(--surface);
|
| border: 1px solid var(--border);
|
| max-width: 100%;
|
| display: flex;
|
| gap: 20px;
|
| flex-wrap: wrap;
|
| font-size: 13px;
|
| }
|
| .msg-stats .stat {
|
| display: flex;
|
| flex-direction: column;
|
| align-items: center;
|
| }
|
| .msg-stats .stat-label {
|
| font-size: 10px;
|
| color: var(--text2);
|
| }
|
| .msg-stats .stat-val {
|
| font-size: 16px;
|
| font-weight: 700;
|
| }
|
| .msg-stats .stat-val.good {
|
| color: var(--green);
|
| }
|
| .msg-stats .stat-val.bad {
|
| color: var(--red);
|
| }
|
| .msg-stats .stat-val.neutral {
|
| color: var(--accent);
|
| }
|
|
|
| .msg-level-up {
|
| background: rgba(210, 153, 34, 0.1);
|
| border: 1px solid var(--orange);
|
| max-width: 100%;
|
| text-align: center;
|
| font-weight: 700;
|
| color: var(--orange);
|
| font-size: 16px;
|
| }
|
|
|
| pre.code {
|
| background: var(--bg);
|
| border: 1px solid var(--border);
|
| border-radius: 6px;
|
| padding: 8px 12px;
|
| margin-top: 6px;
|
| font-family: "Cascadia Code", "Fira Code", monospace;
|
| font-size: 13px;
|
| white-space: pre-wrap;
|
| word-break: break-word;
|
| overflow-x: auto;
|
| }
|
|
|
|
|
| .sidebar {
|
| width: 280px;
|
| background: var(--surface);
|
| border-left: 1px solid var(--border);
|
| padding: 16px;
|
| overflow-y: auto;
|
| flex-shrink: 0;
|
| display: flex;
|
| flex-direction: column;
|
| gap: 16px;
|
| }
|
| .sidebar h3 {
|
| font-size: 12px;
|
| text-transform: uppercase;
|
| color: var(--text2);
|
| letter-spacing: 0.05em;
|
| margin-bottom: 8px;
|
| }
|
| .chart-box {
|
| background: var(--bg);
|
| border: 1px solid var(--border);
|
| border-radius: 8px;
|
| padding: 12px;
|
| }
|
| .chart-box canvas {
|
| width: 100%;
|
| height: 80px;
|
| }
|
| .summary-grid {
|
| display: grid;
|
| grid-template-columns: 1fr 1fr;
|
| gap: 8px;
|
| }
|
| .summary-card {
|
| background: var(--bg);
|
| border: 1px solid var(--border);
|
| border-radius: 8px;
|
| padding: 10px;
|
| text-align: center;
|
| }
|
| .summary-card .val {
|
| font-size: 20px;
|
| font-weight: 700;
|
| }
|
| .summary-card .label {
|
| font-size: 11px;
|
| color: var(--text2);
|
| }
|
|
|
|
|
| .controls {
|
| background: var(--surface);
|
| border-top: 1px solid var(--border);
|
| padding: 10px 20px;
|
| display: flex;
|
| align-items: center;
|
| gap: 12px;
|
| flex-shrink: 0;
|
| }
|
| .controls button {
|
| background: var(--accent);
|
| color: var(--bg);
|
| border: none;
|
| border-radius: 6px;
|
| padding: 6px 16px;
|
| font-size: 13px;
|
| font-weight: 600;
|
| cursor: pointer;
|
| display: flex;
|
| align-items: center;
|
| gap: 6px;
|
| }
|
| .controls button:hover {
|
| filter: brightness(1.15);
|
| }
|
| .controls button.secondary {
|
| background: var(--border);
|
| color: var(--text);
|
| }
|
| .controls button:disabled {
|
| opacity: 0.4;
|
| cursor: not-allowed;
|
| }
|
|
|
|
|
| .timeline-wrapper {
|
| flex: 1;
|
| display: flex;
|
| flex-direction: column;
|
| gap: 2px;
|
| }
|
| .timeline-bar {
|
| width: 100%;
|
| height: 6px;
|
| background: var(--bg);
|
| border-radius: 3px;
|
| cursor: pointer;
|
| position: relative;
|
| }
|
| .timeline-progress {
|
| height: 100%;
|
| background: var(--accent);
|
| border-radius: 3px;
|
| transition: width 0.2s;
|
| }
|
| .timeline-labels {
|
| display: flex;
|
| justify-content: space-between;
|
| font-size: 11px;
|
| color: var(--text2);
|
| }
|
|
|
|
|
| .speed-select {
|
| background: var(--bg);
|
| color: var(--text);
|
| border: 1px solid var(--border);
|
| border-radius: 4px;
|
| padding: 4px 8px;
|
| font-size: 12px;
|
| }
|
|
|
|
|
| .no-data {
|
| display: flex;
|
| flex-direction: column;
|
| align-items: center;
|
| justify-content: center;
|
| height: 100%;
|
| gap: 16px;
|
| color: var(--text2);
|
| }
|
| .no-data .icon {
|
| font-size: 48px;
|
| }
|
| .no-data p {
|
| font-size: 14px;
|
| max-width: 400px;
|
| text-align: center;
|
| }
|
| .no-data input[type="file"] {
|
| display: none;
|
| }
|
| .no-data label {
|
| background: var(--accent);
|
| color: var(--bg);
|
| padding: 8px 20px;
|
| border-radius: 6px;
|
| cursor: pointer;
|
| font-weight: 600;
|
| }
|
|
|
|
|
| .timeline-markers {
|
| position: absolute;
|
| top: 0;
|
| left: 0;
|
| right: 0;
|
| bottom: 0;
|
| pointer-events: none;
|
| }
|
| .timeline-marker {
|
| position: absolute;
|
| top: -2px;
|
| width: 2px;
|
| height: 10px;
|
| border-radius: 1px;
|
| }
|
| .timeline-marker.correct {
|
| background: var(--green);
|
| }
|
| .timeline-marker.incorrect {
|
| background: var(--red);
|
| }
|
| </style>
|
| </head>
|
| <body>
|
| <header>
|
| <h1><span class="icon">🎬</span> PAMPAr Classroom — Replay</h1>
|
| <span class="meta-badge" id="metaModel"><b>—</b></span>
|
| <span class="meta-badge" id="metaDate"><b>—</b></span>
|
| <span class="meta-badge" id="metaLessons"><b>—</b></span>
|
| <span class="meta-badge" id="metaAcc"><b>—</b></span>
|
| </header>
|
|
|
| <div class="main">
|
| <div class="chat-area">
|
| <div class="chat-scroll" id="chatScroll">
|
| <div class="no-data" id="noData">
|
| <div class="icon">🎬</div>
|
| <p>
|
| Carga una grabación de sesión (.html autocontenido) o este archivo
|
| ya tiene datos incorporados.
|
| </p>
|
| <input type="file" id="fileInput" accept=".json,.html" />
|
| <label for="fileInput">📂 Cargar grabación</label>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div class="sidebar">
|
| <div>
|
| <h3>Resumen</h3>
|
| <div class="summary-grid">
|
| <div class="summary-card">
|
| <div class="val" id="sumLessons">0</div>
|
| <div class="label">Lecciones</div>
|
| </div>
|
| <div class="summary-card">
|
| <div class="val" id="sumAccuracy" style="color: var(--green)">
|
| 0%
|
| </div>
|
| <div class="label">Accuracy</div>
|
| </div>
|
| <div class="summary-card">
|
| <div class="val" id="sumLevel" style="color: var(--accent)">
|
| 1
|
| </div>
|
| <div class="label">Nivel final</div>
|
| </div>
|
| <div class="summary-card">
|
| <div class="val" id="sumDuration" style="color: var(--text2)">
|
| 0s
|
| </div>
|
| <div class="label">Duración</div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div class="chart-box">
|
| <h3>Brain Score</h3>
|
| <canvas id="chartBrain"></canvas>
|
| </div>
|
|
|
| <div class="chart-box">
|
| <h3>Loss</h3>
|
| <canvas id="chartLoss"></canvas>
|
| </div>
|
|
|
| <div class="chart-box">
|
| <h3>Accuracy acumulada</h3>
|
| <canvas id="chartAcc"></canvas>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div class="controls">
|
| <button id="btnPlay" disabled>▶ Play</button>
|
| <button id="btnPause" class="secondary" disabled>⏸ Pausa</button>
|
| <button id="btnRestart" class="secondary" disabled>⏮ Inicio</button>
|
|
|
| <div class="timeline-wrapper">
|
| <div class="timeline-bar" id="timelineBar">
|
| <div
|
| class="timeline-progress"
|
| id="timelineProgress"
|
| style="width: 0%"
|
| ></div>
|
| <div class="timeline-markers" id="timelineMarkers"></div>
|
| </div>
|
| <div class="timeline-labels">
|
| <span id="timeCurrent">0:00</span>
|
| <span id="timeTotal">0:00</span>
|
| </div>
|
| </div>
|
|
|
| <select class="speed-select" id="speedSelect">
|
| <option value="0.5">0.5x</option>
|
| <option value="1" selected>1x</option>
|
| <option value="2">2x</option>
|
| <option value="5">5x</option>
|
| <option value="10">10x</option>
|
| <option value="50">50x (rápido)</option>
|
| </select>
|
|
|
| <span class="meta-badge" id="eventCounter">0 / 0</span>
|
| </div>
|
|
|
| <script>
|
|
|
|
|
|
|
| let recording = null;
|
| let eventIdx = 0;
|
| let playing = false;
|
| let playTimer = null;
|
| let speed = 1;
|
|
|
|
|
| const chartData = { brain: [], loss: [], acc: [] };
|
|
|
|
|
| document.addEventListener("DOMContentLoaded", () => {
|
|
|
| const fileInput = document.getElementById("fileInput");
|
| if (fileInput) fileInput.addEventListener("change", handleFile);
|
|
|
| document.getElementById("btnPlay").addEventListener("click", play);
|
| document.getElementById("btnPause").addEventListener("click", pause);
|
| document
|
| .getElementById("btnRestart")
|
| .addEventListener("click", restart);
|
| document
|
| .getElementById("speedSelect")
|
| .addEventListener("change", (e) => {
|
| speed = parseFloat(e.target.value);
|
| });
|
| document
|
| .getElementById("timelineBar")
|
| .addEventListener("click", seekTimeline);
|
|
|
|
|
| if (window.__RECORDING__) {
|
| loadRecording(window.__RECORDING__);
|
| }
|
| });
|
|
|
| function handleFile(e) {
|
| const file = e.target.files[0];
|
| if (!file) return;
|
| const reader = new FileReader();
|
| reader.onload = (ev) => {
|
| let text = ev.target.result;
|
|
|
| if (file.name.endsWith(".html")) {
|
| const match = text.match(
|
| /window\.__RECORDING__\s*=\s*(\{[\s\S]*?\});/,
|
| );
|
| if (match) {
|
| try {
|
| loadRecording(JSON.parse(match[1]));
|
| return;
|
| } catch {}
|
| }
|
| }
|
|
|
| try {
|
| loadRecording(JSON.parse(text));
|
| } catch (er) {
|
| alert("Formato no reconocido");
|
| }
|
| };
|
| reader.readAsText(file);
|
| }
|
|
|
| function loadRecording(data) {
|
| recording = data;
|
| eventIdx = 0;
|
| chartData.brain.length = 0;
|
| chartData.loss.length = 0;
|
| chartData.acc.length = 0;
|
|
|
|
|
| const m = data.meta || {};
|
| document.getElementById("metaModel").innerHTML =
|
| `<b>${m.teacher_model || "?"}</b>`;
|
| document.getElementById("metaDate").innerHTML =
|
| `<b>${m.start_time || "?"}</b>`;
|
| document.getElementById("metaLessons").innerHTML =
|
| `<b>${m.total_lessons || 0}</b> lecciones`;
|
| document.getElementById("metaAcc").innerHTML =
|
| `Acc: <b>${((m.accuracy || 0) * 100).toFixed(1)}%</b>`;
|
|
|
|
|
| document.getElementById("sumLessons").textContent =
|
| m.total_lessons || 0;
|
| document.getElementById("sumAccuracy").textContent =
|
| ((m.accuracy || 0) * 100).toFixed(1) + "%";
|
| document.getElementById("sumLevel").textContent = m.final_level || 1;
|
| document.getElementById("sumDuration").textContent = formatDuration(
|
| m.duration_s || 0,
|
| );
|
|
|
|
|
| const totalT =
|
| data.events.length > 0 ? data.events[data.events.length - 1].t : 0;
|
| document.getElementById("timeTotal").textContent = formatTime(totalT);
|
|
|
|
|
| buildTimelineMarkers(data.events, totalT);
|
|
|
|
|
| document.getElementById("btnPlay").disabled = false;
|
| document.getElementById("btnRestart").disabled = false;
|
|
|
|
|
| document.getElementById("noData").style.display = "none";
|
|
|
|
|
| clearChat();
|
| updateCounter();
|
| }
|
|
|
| function buildTimelineMarkers(events, totalT) {
|
| const container = document.getElementById("timelineMarkers");
|
| container.innerHTML = "";
|
| if (totalT <= 0) return;
|
| for (const ev of events) {
|
| if (ev.event === "lesson_complete") {
|
| const d = typeof ev.data === "object" ? ev.data : {};
|
| const pct = (ev.t / totalT) * 100;
|
| const marker = document.createElement("div");
|
| marker.className =
|
| "timeline-marker " + (d.correct ? "correct" : "incorrect");
|
| marker.style.left = pct + "%";
|
| container.appendChild(marker);
|
| }
|
| }
|
| }
|
|
|
|
|
| function play() {
|
| if (!recording || eventIdx >= recording.events.length) return;
|
| playing = true;
|
| document.getElementById("btnPlay").disabled = true;
|
| document.getElementById("btnPause").disabled = false;
|
| scheduleNext();
|
| }
|
|
|
| function pause() {
|
| playing = false;
|
| clearTimeout(playTimer);
|
| document.getElementById("btnPlay").disabled = false;
|
| document.getElementById("btnPause").disabled = true;
|
| }
|
|
|
| function restart() {
|
| pause();
|
| eventIdx = 0;
|
| chartData.brain.length = 0;
|
| chartData.loss.length = 0;
|
| chartData.acc.length = 0;
|
| clearChat();
|
| updateTimeline();
|
| updateCounter();
|
| drawCharts();
|
| }
|
|
|
| function scheduleNext() {
|
| if (!playing || !recording || eventIdx >= recording.events.length) {
|
| pause();
|
| return;
|
| }
|
|
|
| const ev = recording.events[eventIdx];
|
| renderEvent(ev);
|
| eventIdx++;
|
| updateTimeline();
|
| updateCounter();
|
|
|
| if (eventIdx >= recording.events.length) {
|
| pause();
|
| return;
|
| }
|
|
|
|
|
| const nextEv = recording.events[eventIdx];
|
| let delay = ((nextEv.t - ev.t) * 1000) / speed;
|
| delay = Math.max(delay, 20);
|
| delay = Math.min(delay, 5000 / speed);
|
|
|
| playTimer = setTimeout(scheduleNext, delay);
|
| }
|
|
|
| function seekTimeline(e) {
|
| if (!recording) return;
|
| const bar = document.getElementById("timelineBar");
|
| const rect = bar.getBoundingClientRect();
|
| const pct = (e.clientX - rect.left) / rect.width;
|
| const totalT = recording.events[recording.events.length - 1].t;
|
| const targetT = pct * totalT;
|
|
|
|
|
| pause();
|
| eventIdx = 0;
|
| chartData.brain.length = 0;
|
| chartData.loss.length = 0;
|
| chartData.acc.length = 0;
|
| clearChat();
|
|
|
| while (
|
| eventIdx < recording.events.length &&
|
| recording.events[eventIdx].t <= targetT
|
| ) {
|
| renderEvent(recording.events[eventIdx]);
|
| eventIdx++;
|
| }
|
| updateTimeline();
|
| updateCounter();
|
| }
|
|
|
|
|
| function renderEvent(ev) {
|
| const container = document.getElementById("chatScroll");
|
| const d = typeof ev.data === "object" ? ev.data : {};
|
| const ts = formatTime(ev.t);
|
| let el = null;
|
|
|
| switch (ev.event) {
|
| case "system":
|
| el = makeMsg(
|
| "msg-system",
|
| typeof ev.data === "string"
|
| ? ev.data
|
| : d.message || JSON.stringify(d),
|
| );
|
| break;
|
|
|
| case "lesson_start":
|
| el = makeMsg(
|
| "msg-lesson-start",
|
| `<span class="badge">Lección ${d.lesson_id || "?"}</span> ` +
|
| `Nivel ${d.level || "?"} — ${d.level_name || ""}<br>` +
|
| `<span style="color:var(--text2)">${escapeHtml(d.problem || "")}</span>`,
|
| );
|
| break;
|
|
|
| case "student_answer":
|
| el = makeMsg(
|
| "msg-student",
|
| `<pre class="code">${escapeHtml(d.answer || "")}</pre>`,
|
| );
|
| break;
|
|
|
| case "student_thinking":
|
| el = makeMsg("msg-system", "🤔 Alumno pensando...");
|
| el.style.opacity = "0.6";
|
| break;
|
|
|
| case "teacher_evaluating":
|
| el = makeMsg("msg-system", "📝 Profesor evaluando...");
|
| el.style.opacity = "0.6";
|
| break;
|
|
|
| case "teacher_feedback": {
|
| const cls = d.correct ? "correct" : "incorrect";
|
| const icon = d.correct ? "✅" : "❌";
|
| el = makeMsg(
|
| "msg-teacher " + cls,
|
| `${icon} ${escapeHtml(d.feedback || "")}`,
|
| );
|
| break;
|
| }
|
|
|
| case "teacher_solution":
|
| el = makeMsg(
|
| "msg-solution",
|
| `<pre class="code">${escapeHtml(d.solution || "")}</pre>`,
|
| );
|
| break;
|
|
|
| case "training":
|
|
|
| break;
|
|
|
| case "lesson_complete":
|
| el = document.createElement("div");
|
| el.className = "msg msg-stats";
|
| el.innerHTML = `
|
| <div class="stat"><span class="stat-label">Loss</span><span class="stat-val neutral">${(d.loss || 0).toFixed(4)}</span></div>
|
| <div class="stat"><span class="stat-label">EWC</span><span class="stat-val neutral">${(d.ewc_penalty || 0).toFixed(6)}</span></div>
|
| <div class="stat"><span class="stat-label">Brain</span><span class="stat-val ${(d.brain_score || 0) > 0.8 ? "good" : "bad"}">${((d.brain_score || 0) * 100).toFixed(1)}%</span></div>
|
| <div class="stat"><span class="stat-label">Accuracy</span><span class="stat-val ${(d.accuracy || 0) > 0.5 ? "good" : "bad"}">${((d.accuracy || 0) * 100).toFixed(1)}%</span></div>
|
| <div class="stat"><span class="stat-label">Nivel</span><span class="stat-val neutral">${d.level || "?"}</span></div>
|
| <div class="stat"><span class="stat-label">Replay</span><span class="stat-val neutral">${d.replay_size || 0}</span></div>
|
| `;
|
|
|
| chartData.brain.push(d.brain_score || 0);
|
| chartData.loss.push(d.loss || 0);
|
| chartData.acc.push(d.accuracy || 0);
|
| drawCharts();
|
| break;
|
|
|
| case "level_up":
|
| el = makeMsg(
|
| "msg-level-up",
|
| `🎉 ¡NIVEL UP! → Nivel ${d.new_level}: ${d.nombre || ""}`,
|
| );
|
| break;
|
|
|
| case "checkpoint":
|
| el = makeMsg(
|
| "msg-system",
|
| `💾 Checkpoint guardado — lección ${d.lesson || "?"}`,
|
| );
|
| break;
|
|
|
| case "error":
|
| el = makeMsg(
|
| "msg-system",
|
| `❗ ${typeof ev.data === "string" ? ev.data : JSON.stringify(d)}`,
|
| );
|
| el.style.borderColor = "var(--red)";
|
| el.style.color = "var(--red)";
|
| break;
|
|
|
| default:
|
|
|
| el = makeMsg(
|
| "msg-system",
|
| `[${ev.event}] ${JSON.stringify(ev.data).substring(0, 200)}`,
|
| );
|
| el.style.opacity = "0.5";
|
| }
|
|
|
| if (el) {
|
|
|
| const tsBadge = document.createElement("span");
|
| tsBadge.className = "ts";
|
| tsBadge.textContent = ts;
|
| el.appendChild(tsBadge);
|
|
|
| container.appendChild(el);
|
| container.scrollTop = container.scrollHeight;
|
| }
|
| }
|
|
|
| function makeMsg(className, html) {
|
| const div = document.createElement("div");
|
| div.className = "msg " + className;
|
| div.innerHTML = html;
|
| return div;
|
| }
|
|
|
| function clearChat() {
|
| const container = document.getElementById("chatScroll");
|
| container.innerHTML = "";
|
| }
|
|
|
| function escapeHtml(text) {
|
| const div = document.createElement("div");
|
| div.textContent = text;
|
| return div.innerHTML;
|
| }
|
|
|
|
|
| function updateTimeline() {
|
| if (!recording || recording.events.length === 0) return;
|
| const totalT = recording.events[recording.events.length - 1].t;
|
| const currentT = eventIdx > 0 ? recording.events[eventIdx - 1].t : 0;
|
| const pct = totalT > 0 ? (currentT / totalT) * 100 : 0;
|
|
|
| document.getElementById("timelineProgress").style.width = pct + "%";
|
| document.getElementById("timeCurrent").textContent =
|
| formatTime(currentT);
|
| }
|
|
|
| function updateCounter() {
|
| const total = recording ? recording.events.length : 0;
|
| document.getElementById("eventCounter").textContent =
|
| `${eventIdx} / ${total}`;
|
| }
|
|
|
|
|
| function drawCharts() {
|
| drawMiniChart(
|
| "chartBrain",
|
| chartData.brain,
|
| "rgba(63,185,80,0.8)",
|
| 0,
|
| 1,
|
| );
|
| drawMiniChart("chartLoss", chartData.loss, "rgba(248,81,73,0.8)");
|
| drawMiniChart("chartAcc", chartData.acc, "rgba(88,166,255,0.8)", 0, 1);
|
| }
|
|
|
| function drawMiniChart(canvasId, data, color, forcedMin, forcedMax) {
|
| const canvas = document.getElementById(canvasId);
|
| if (!canvas) return;
|
| const ctx = canvas.getContext("2d");
|
| const W = (canvas.width = canvas.offsetWidth * 2);
|
| const H = (canvas.height = canvas.offsetHeight * 2);
|
| ctx.clearRect(0, 0, W, H);
|
|
|
| if (data.length < 2) return;
|
|
|
| let minV = forcedMin !== undefined ? forcedMin : Math.min(...data);
|
| let maxV = forcedMax !== undefined ? forcedMax : Math.max(...data);
|
| if (maxV === minV) maxV = minV + 1;
|
|
|
| const pad = 4;
|
|
|
| ctx.beginPath();
|
| ctx.strokeStyle = color;
|
| ctx.lineWidth = 2;
|
|
|
| for (let i = 0; i < data.length; i++) {
|
| const x = pad + (i / (data.length - 1)) * (W - 2 * pad);
|
| const y =
|
| H - pad - ((data[i] - minV) / (maxV - minV)) * (H - 2 * pad);
|
| if (i === 0) ctx.moveTo(x, y);
|
| else ctx.lineTo(x, y);
|
| }
|
| ctx.stroke();
|
|
|
|
|
| ctx.lineTo(
|
| pad + ((data.length - 1) / (data.length - 1)) * (W - 2 * pad),
|
| H - pad,
|
| );
|
| ctx.lineTo(pad, H - pad);
|
| ctx.closePath();
|
| ctx.fillStyle = color.replace(/[\d.]+\)$/, "0.1)");
|
| ctx.fill();
|
|
|
|
|
| const last = data[data.length - 1];
|
| ctx.fillStyle = color;
|
| ctx.font = `bold ${Math.round(H * 0.18)}px system-ui`;
|
| ctx.textAlign = "right";
|
| ctx.fillText(
|
| forcedMax === 1 ? (last * 100).toFixed(1) + "%" : last.toFixed(4),
|
| W - pad,
|
| pad + H * 0.18,
|
| );
|
| }
|
|
|
|
|
| function formatTime(seconds) {
|
| const m = Math.floor(seconds / 60);
|
| const s = Math.floor(seconds % 60);
|
| return `${m}:${String(s).padStart(2, "0")}`;
|
| }
|
|
|
| function formatDuration(seconds) {
|
| if (seconds < 60) return Math.round(seconds) + "s";
|
| if (seconds < 3600) return Math.round(seconds / 60) + "min";
|
| const h = Math.floor(seconds / 3600);
|
| const m = Math.round((seconds % 3600) / 60);
|
| return `${h}h ${m}min`;
|
| }
|
| </script>
|
| </body>
|
| </html>
|
|
|