Download frontend/stats.html from r-vasanthkumar73-dev/Sentinel-Multimodal-Emotion-AI: direct link, hf CLI and curl.
- Browser
- Download file 29.9 kB
-
https://huggingface.co/spaces/r-vasanthkumar73-dev/Sentinel-Multimodal-Emotion-AI/resolve/main/frontend/stats.html
- Command line
-
hf download hf://spaces/r-vasanthkumar73-dev/Sentinel-Multimodal-Emotion-AI/frontend/stats.html
-
curl -L -o stats.html https://huggingface.co/spaces/r-vasanthkumar73-dev/Sentinel-Multimodal-Emotion-AI/resolve/main/frontend/stats.html
29.9 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Instructor Nexus — Student Performance</title> | |
| <link rel="stylesheet" href="/css/styles.css"> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" | |
| rel="stylesheet"> | |
| <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" | |
| rel="stylesheet"> | |
| <style> | |
| .perf-content { | |
| padding: 32px; | |
| } | |
| .overview-grid { | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| gap: 20px; | |
| margin-bottom: 32px; | |
| } | |
| .overview-card { | |
| padding: 24px; | |
| border-radius: 16px; | |
| background: var(--surface-container-low); | |
| border: 1px solid rgba(59, 73, 75, 0.1); | |
| transition: all 0.3s; | |
| } | |
| .overview-card:hover { | |
| border-color: rgba(0, 240, 255, 0.15); | |
| transform: translateY(-2px); | |
| } | |
| .overview-icon { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 16px; | |
| } | |
| .overview-label { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--outline); | |
| font-weight: 600; | |
| margin-bottom: 4px; | |
| } | |
| .overview-value { | |
| font-family: var(--font-headline); | |
| font-size: 2rem; | |
| font-weight: 800; | |
| } | |
| .sections-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 24px; | |
| margin-bottom: 32px; | |
| } | |
| .session-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| .session-table th { | |
| text-align: left; | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--outline); | |
| font-weight: 600; | |
| padding: 12px; | |
| border-bottom: 1px solid rgba(59, 73, 75, 0.15); | |
| } | |
| .session-table td { | |
| padding: 12px; | |
| border-bottom: 1px solid rgba(59, 73, 75, 0.08); | |
| font-size: 13px; | |
| color: var(--on-surface-variant); | |
| } | |
| .session-table tr:hover td { | |
| background: rgba(0, 240, 255, 0.02); | |
| } | |
| .chart-area { | |
| height: 200px; | |
| position: relative; | |
| margin-top: 16px; | |
| overflow: hidden; | |
| } | |
| .chart-area canvas { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 48px; | |
| color: var(--outline); | |
| } | |
| .empty-state .material-symbols-outlined { | |
| font-size: 64px; | |
| margin-bottom: 16px; | |
| opacity: 0.3; | |
| } | |
| .student-id-bar { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| margin-bottom: 32px; | |
| } | |
| .toast { | |
| visibility: hidden; | |
| min-width: 250px; | |
| background-color: #ff4a4a; | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 8px; | |
| padding: 16px; | |
| position: fixed; | |
| z-index: 1000; | |
| left: 50%; | |
| bottom: 30px; | |
| transform: translateX(-50%); | |
| font-family: var(--font-headline); | |
| font-weight: 600; | |
| font-size: 14px; | |
| box-shadow: 0 4px 12px rgba(255, 74, 74, 0.3); | |
| transition: opacity 0.3s, bottom 0.3s; | |
| opacity: 0; | |
| } | |
| .toast.show { | |
| visibility: visible; | |
| opacity: 1; | |
| bottom: 50px; | |
| } | |
| @media (max-width: 1024px) { | |
| .overview-grid { | |
| grid-template-columns: repeat(2, 1fr); | |
| } | |
| .sections-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| @keyframes spin { | |
| 100% { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <aside class="sidebar"> | |
| <div class="sidebar-logo">S</div> | |
| <nav class="sidebar-nav"> | |
| <a href="/" class="sidebar-link"><span class="material-symbols-outlined">dashboard</span><span | |
| class="label">Home</span></a> | |
| <a href="/live" class="sidebar-link"><span class="material-symbols-outlined">sensors</span><span | |
| class="label">Live</span></a> | |
| <a href="/scan" class="sidebar-link"><span class="material-symbols-outlined">analytics</span><span | |
| class="label">Scan</span></a> | |
| <a href="/stats" class="sidebar-link active"><span class="material-symbols-outlined" | |
| style="font-variation-settings:'FILL' 1;">school</span><span class="label">Stats</span></a> | |
| </nav> | |
| </aside> | |
| <header class="topbar"> | |
| <div style="display:flex;align-items:center;gap:16px;"> | |
| <h1 class="topbar-title gradient-text-tertiary">Instructor Nexus</h1> | |
| <div class="status-pill"><span class="status-dot" style="background:var(--tertiary-fixed-dim);"></span><span | |
| class="status-text" style="color:var(--tertiary-fixed-dim);">Performance Hub</span></div> | |
| </div> | |
| <button class="btn-secondary" onclick="refreshData(event)"><span class="material-symbols-outlined" | |
| style="font-size:18px;">refresh</span> Refresh Data</button> | |
| </header> | |
| <main class="main-content"> | |
| <div class="perf-content"> | |
| <div class="animate-fade-in"> | |
| <h2 style="font-family:var(--font-headline);font-size:1.75rem;font-weight:700;margin-bottom:8px;"> | |
| Session Performance Tracker</h2> | |
| <p style="color:var(--on-surface-variant);font-size:0.9rem;margin-bottom:24px;">Monitor current and | |
| historical engagement, emotion trends, and drill down into specific sessions.</p> | |
| </div> | |
| <!-- Session ID Input --> | |
| <div class="student-id-bar"> | |
| <input class="sentinel-input" id="student-id-input" placeholder="Enter Session ID (leave empty for all)" | |
| value="" style="max-width:300px;"> | |
| <button class="btn-primary" onclick="loadPerformance(event)"><span class="material-symbols-outlined" | |
| style="font-size:18px;">search</span> Load Data</button> | |
| </div> | |
| <!-- Overview Cards --> | |
| <div class="overview-grid stagger-children" id="overview-cards"> | |
| <div class="overview-card"> | |
| <div class="overview-icon" style="background:rgba(0,240,255,0.1);"><span | |
| class="material-symbols-outlined" style="color:var(--primary-container);">monitoring</span> | |
| </div> | |
| <div class="overview-label">Total Sessions</div> | |
| <div class="overview-value" style="color:var(--primary-container);" id="ov-sessions">—</div> | |
| <div id="ov-session-id" style="font-size:11px;color:var(--outline);margin-top:4px;">Global Data</div> | |
| <div id="ov-session-duration" style="font-size:11px;color:var(--primary-container);margin-top:2px;"></div> | |
| <div id="ov-session-date" style="font-size:11px;color:var(--outline);margin-top:2px;"></div> | |
| </div> | |
| <div class="overview-card"> | |
| <div class="overview-icon" style="background:rgba(209,188,255,0.1);"><span | |
| class="material-symbols-outlined" style="color:var(--secondary);" id="ov-avg-icon">trending_up</span></div> | |
| <div class="overview-label" id="ov-avg-label">Avg Engagement</div> | |
| <div class="overview-value" style="color:var(--secondary);" id="ov-avg">—</div> | |
| </div> | |
| <div class="overview-card"> | |
| <div class="overview-icon" style="background:rgba(0,240,255,0.1);"><span | |
| class="material-symbols-outlined" | |
| style="color:var(--primary-container);" id="ov-peak-icon">emoji_events</span></div> | |
| <div class="overview-label" id="ov-peak-label">Peak Engagement</div> | |
| <div class="overview-value" style="color:var(--primary-container);" id="ov-peak">—</div> | |
| </div> | |
| <div class="overview-card"> | |
| <div class="overview-icon" style="background:rgba(255,180,171,0.1);"><span | |
| class="material-symbols-outlined" style="color:var(--error);" id="ov-min-icon">trending_down</span></div> | |
| <div class="overview-label" id="ov-min-label">Min Engagement</div> | |
| <div class="overview-value" style="color:var(--error);" id="ov-min">—</div> | |
| </div> | |
| </div> | |
| <!-- Charts & Session History --> | |
| <div class="sections-grid"> | |
| <!-- Engagement Over Time Chart --> | |
| <div class="glass-card" style="padding:24px;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> | |
| <div> | |
| <div | |
| style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--outline);font-weight:600;"> | |
| Engagement Timeline</div> | |
| <div style="font-family:var(--font-headline);font-size:1.1rem;font-weight:700;">Historical | |
| Trend</div> | |
| </div> | |
| <span class="material-symbols-outlined" style="color:var(--primary-container);">timeline</span> | |
| </div> | |
| <div class="chart-area"> | |
| <canvas id="history-chart"></canvas> | |
| </div> | |
| </div> | |
| <!-- Session History Table --> | |
| <div class="glass-card" style="padding:24px;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> | |
| <div> | |
| <div | |
| style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--outline);font-weight:600;"> | |
| Session Log</div> | |
| <div style="font-family:var(--font-headline);font-size:1.1rem;font-weight:700;">Recent | |
| Sessions</div> | |
| </div> | |
| <span class="material-symbols-outlined" style="color:var(--secondary);">history</span> | |
| </div> | |
| <div style="overflow-y:auto;max-height:240px;" id="session-table-container"> | |
| <div class="empty-state"> | |
| <span class="material-symbols-outlined">inbox</span> | |
| <div style="font-family:var(--font-headline);font-weight:600;">No sessions yet</div> | |
| <div style="font-size:12px;margin-top:4px;">Start a monitoring session from the Live Pulse | |
| page</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Performance Records --> | |
| <div class="glass-card" style="padding:24px;margin-bottom:32px;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> | |
| <div> | |
| <div | |
| style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--outline);font-weight:600;"> | |
| Performance Records</div> | |
| <div style="font-family:var(--font-headline);font-size:1.1rem;font-weight:700;">Detailed History | |
| </div> | |
| </div> | |
| <a href="/api/sessions/export" title="Export to CSV" style="color:var(--tertiary-fixed-dim); text-decoration:none; transition:transform 0.2s; display:flex; align-items:center; gap:8px; padding: 0 16px; height:36px; border-radius:8px; background:rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);" onmouseover="this.style.transform='scale(1.05)'; this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.transform='scale(1)'; this.style.background='rgba(255,255,255,0.05)'"> | |
| <span class="material-symbols-outlined" style="font-size: 18px; color: #f4d03f;">description</span> | |
| <span style="font-family: var(--font-headline); font-size: 13px; font-weight: 600; color: #f4d03f; letter-spacing: 0.5px;">EXPORT CSV</span> | |
| </a> | |
| </div> | |
| <div id="performance-records" style="overflow-y:auto;max-height:300px;"> | |
| <div class="empty-state"> | |
| <span class="material-symbols-outlined">folder_off</span> | |
| <div style="font-family:var(--font-headline);font-weight:600;">No performance data</div> | |
| <div style="font-size:12px;margin-top:4px;">Complete a monitoring session to see performance | |
| records</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <div id="toast" class="toast">User Not Found</div> | |
| <script> | |
| function showToast(message) { | |
| const toast = document.getElementById("toast"); | |
| toast.textContent = message; | |
| toast.className = "toast show"; | |
| setTimeout(function () { toast.className = toast.className.replace("show", ""); }, 3000); | |
| } | |
| window.onload = () => { | |
| loadPerformance(); | |
| refreshData(); | |
| }; | |
| async function loadPerformance(event) { | |
| let loadBtn = null; | |
| let originalText = ''; | |
| // If triggered by a button click, show loading state on that button | |
| if (event && event.currentTarget) { | |
| loadBtn = event.currentTarget; | |
| originalText = loadBtn.innerHTML; | |
| const isRefresh = originalText.includes('refresh'); | |
| loadBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:18px; animation: spin 1s linear infinite;">${isRefresh ? 'refresh' : 'sync'}</span> ${isRefresh ? 'Refreshing...' : 'Loading...'}`; | |
| loadBtn.disabled = true; | |
| } | |
| let studentId = document.getElementById('student-id-input').value.trim(); | |
| if (!studentId) studentId = 'all'; | |
| try { | |
| const res = await fetch(`/api/stats/${encodeURIComponent(studentId)}`); | |
| if (res.status === 404) { | |
| showToast("Session Not Found"); | |
| throw new Error("Session Not Found"); | |
| } | |
| const stats = await res.json(); | |
| updateOverview(stats); | |
| // Show success state briefly | |
| if (loadBtn) { | |
| loadBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:18px;">check</span> Loaded`; | |
| setTimeout(() => { | |
| loadBtn.innerHTML = originalText; | |
| loadBtn.disabled = false; | |
| }, 1500); | |
| } | |
| } catch (e) { | |
| console.error('Failed to load stats:', e); | |
| if (loadBtn) { | |
| loadBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">error</span> Failed'; | |
| setTimeout(() => { | |
| loadBtn.innerHTML = originalText; | |
| loadBtn.disabled = false; | |
| }, 2000); | |
| } | |
| } | |
| } | |
| async function refreshData(event) { | |
| let loadBtn = null; | |
| let originalText = ''; | |
| if (event && event.currentTarget) { | |
| loadBtn = event.currentTarget; | |
| originalText = loadBtn.innerHTML; | |
| loadBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:18px; animation: spin 1s linear infinite;">refresh</span> Refreshing...`; | |
| loadBtn.disabled = true; | |
| } | |
| try { | |
| const res = await fetch('/api/sessions/latest'); | |
| const data = await res.json(); | |
| updateSessionTable(data.sessions); | |
| updatePerformanceRecords(data.performance); | |
| drawHistoryChart(data.performance); | |
| if (loadBtn) { | |
| loadBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:18px;">check</span> Refreshed`; | |
| setTimeout(() => { | |
| loadBtn.innerHTML = originalText; | |
| loadBtn.disabled = false; | |
| }, 1500); | |
| } | |
| } catch (e) { | |
| console.error('Failed to refresh data:', e); | |
| if (loadBtn) { | |
| loadBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">error</span> Failed'; | |
| setTimeout(() => { | |
| loadBtn.innerHTML = originalText; | |
| loadBtn.disabled = false; | |
| }, 2000); | |
| } | |
| } | |
| } | |
| function updateOverview(stats) { | |
| if (!stats) return; | |
| document.getElementById('ov-sessions').textContent = stats.total_sessions || 0; | |
| document.getElementById('ov-avg').textContent = stats.avg_engagement ? stats.avg_engagement.toFixed(1) + '%' : '—'; | |
| document.getElementById('ov-peak').textContent = stats.peak_engagement ? stats.peak_engagement.toFixed(1) + '%' : '—'; | |
| document.getElementById('ov-min').textContent = stats.min_engagement ? stats.min_engagement.toFixed(1) + '%' : '—'; | |
| // Handle session specific drill-down | |
| if (stats.session_info) { | |
| // Update Card 1 subtitle | |
| document.getElementById('ov-session-id').textContent = 'Session Detailed View'; | |
| // Update Card 2 to show Session ID | |
| document.getElementById('ov-avg-label').textContent = 'SESSION ID'; | |
| document.getElementById('ov-avg').innerHTML = `<span class="gradient-text-tertiary">#${stats.session_info.id}</span>`; | |
| document.getElementById('ov-avg-icon').textContent = 'tag'; | |
| // Update Card 3 to show Duration | |
| document.getElementById('ov-peak-label').textContent = 'SESSION DURATION'; | |
| const dur = stats.session_info.duration_mins || "0 seconds"; | |
| document.getElementById('ov-peak').innerHTML = `<span style="color:#ff0000 !important; font-size: 2.5rem;">${dur}</span>`; | |
| document.getElementById('ov-peak-icon').textContent = 'timer'; | |
| // Update Card 4 to show Date | |
| document.getElementById('ov-min-label').textContent = 'SESSION DATE'; | |
| const dateStr = stats.session_info.date_time || '—'; | |
| document.getElementById('ov-min').innerHTML = `<span style="font-size: 1.5rem; color:var(--primary-container);">${dateStr}</span>`; | |
| document.getElementById('ov-min-icon').textContent = 'calendar_today'; | |
| // Clear old sub-info in card 1 | |
| document.getElementById('ov-session-duration').textContent = ''; | |
| document.getElementById('ov-session-date').textContent = ''; | |
| highlightSessionRow(stats.session_info.id); | |
| } else { | |
| // Reset to Global Labels | |
| document.getElementById('ov-session-id').textContent = 'Global Data'; | |
| document.getElementById('ov-session-duration').textContent = ''; | |
| document.getElementById('ov-session-date').textContent = ''; | |
| document.getElementById('ov-avg-label').textContent = 'Avg Engagement'; | |
| document.getElementById('ov-avg-icon').textContent = 'trending_up'; | |
| document.getElementById('ov-peak-label').textContent = 'Peak Engagement'; | |
| document.getElementById('ov-peak-icon').textContent = 'emoji_events'; | |
| document.getElementById('ov-min-label').textContent = 'Min Engagement'; | |
| document.getElementById('ov-min-icon').textContent = 'trending_down'; | |
| // Reset values will be handled by the code above this if-block | |
| highlightSessionRow(null); | |
| } | |
| } | |
| function highlightSessionRow(sessionId) { | |
| const rows = document.querySelectorAll('#session-table-container tr'); | |
| rows.forEach(row => { | |
| row.style.backgroundColor = ''; | |
| if (sessionId && row.getAttribute('data-session-id') === String(sessionId)) { | |
| row.style.backgroundColor = 'rgba(0,240,255,0.15)'; | |
| // Scroll into view gently | |
| row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| }); | |
| } | |
| function updateSessionTable(sessions) { | |
| const container = document.getElementById('session-table-container'); | |
| if (!sessions || sessions.length === 0) { | |
| container.innerHTML = '<div class="empty-state"><span class="material-symbols-outlined">inbox</span><div style="font-family:var(--font-headline);font-weight:600;">No sessions yet</div></div>'; | |
| return; | |
| } | |
| let html = '<table class="session-table"><thead><tr><th>ID</th><th>Started</th><th>Engagement</th><th>Emotion</th><th>Actions</th></tr></thead><tbody>'; | |
| sessions.forEach(s => { | |
| const started = s.start_time ? new Date(s.start_time).toLocaleString() : '—'; | |
| html += `<tr data-session-id="${s.id}"> | |
| <td>#${s.id}</td> | |
| <td>${started}</td> | |
| <td><span style="color:var(--primary-container);font-weight:700;">${s.avg_engagement ? s.avg_engagement.toFixed(1) + '%' : '—'}</span></td> | |
| <td><span class="emotion-chip ${(s.dominant_emotion || 'neutral').toLowerCase()}">${s.dominant_emotion || 'neutral'}</span></td> | |
| <td> | |
| <button onclick="deleteSession(${s.id})" style="background:transparent;border:none;color:var(--error);cursor:pointer;opacity:0.7;transition:0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7" title="Delete Session"> | |
| <span class="material-symbols-outlined" style="font-size: 18px;">delete</span> | |
| </button> | |
| </td> | |
| </tr>`; | |
| }); | |
| html += '</tbody></table>'; | |
| container.innerHTML = html; | |
| // Re-highlight if needed | |
| let studentId = document.getElementById('student-id-input').value.trim(); | |
| if (studentId && studentId !== 'all') highlightSessionRow(studentId); | |
| } | |
| async function deleteSession(sessionId) { | |
| if (!confirm(`Are you sure you want to permanently delete Session #${sessionId}? This will also delete all associated logs and dynamically renumber subsequent sessions.`)) { | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' }); | |
| if (res.ok) { | |
| refreshData(); // Automatically reload the table and total count | |
| // If the currently searched session was deleted or shifted, clear search | |
| let currentSearch = document.getElementById('student-id-input').value.trim(); | |
| if (currentSearch == sessionId) { | |
| document.getElementById('student-id-input').value = ''; | |
| loadPerformance(); | |
| } | |
| } else { | |
| alert("Failed to delete session."); | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| alert("An error occurred while deleting the session."); | |
| } | |
| } | |
| function updatePerformanceRecords(perf) { | |
| const container = document.getElementById('performance-records'); | |
| if (!perf || perf.length === 0) { | |
| container.innerHTML = '<div class="empty-state"><span class="material-symbols-outlined">folder_off</span><div style="font-family:var(--font-headline);font-weight:600;">No performance data</div></div>'; | |
| return; | |
| } | |
| let html = '<table class="session-table"><thead><tr><th>Session ID</th><th>Date</th><th>Engagement</th><th>Summary</th></tr></thead><tbody>'; | |
| perf.forEach(p => { | |
| const sessionLabel = p.session_id ? `#${p.session_id}` : '—'; | |
| html += `<tr> | |
| <td style="color:var(--primary-container);font-weight:700;">${sessionLabel}</td> | |
| <td>${p.date || '—'}</td> | |
| <td><span style="color:var(--secondary);font-weight:700;">${p.engagement_score ? p.engagement_score.toFixed(1) + '%' : '—'}</span></td> | |
| <td style="font-size:11px;">${p.overall_summary || '—'}</td> | |
| </tr>`; | |
| }); | |
| html += '</tbody></table>'; | |
| container.innerHTML = html; | |
| } | |
| function drawHistoryChart(perf) { | |
| const canvas = document.getElementById('history-chart'); | |
| const ctx = canvas.getContext('2d'); | |
| const rect = canvas.parentElement.getBoundingClientRect(); | |
| canvas.width = rect.width * window.devicePixelRatio; | |
| canvas.height = rect.height * window.devicePixelRatio; | |
| ctx.scale(window.devicePixelRatio, window.devicePixelRatio); | |
| const w = rect.width; | |
| const h = rect.height; | |
| ctx.clearRect(0, 0, w, h); | |
| if (!perf || perf.length === 0) { | |
| ctx.fillStyle = 'rgba(132,148,149,0.3)'; | |
| ctx.font = '13px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('No data available', w / 2, h / 2); | |
| return; | |
| } | |
| const data = perf.map(p => p.engagement_score || 0).reverse(); | |
| if (data.length < 2) return; | |
| // Dynamic Theme Colors | |
| const isLight = document.documentElement.getAttribute('data-theme') === 'light'; | |
| const primaryColor = isLight ? '#0284c7' : '#00f0ff'; | |
| const primaryRGBA = isLight ? '2, 132, 199' : '0, 240, 255'; | |
| const gridColor = isLight ? 'rgba(148,163,184,0.3)' : 'rgba(59,73,75,0.1)'; | |
| // Grid lines | |
| ctx.strokeStyle = gridColor; | |
| ctx.lineWidth = 1; | |
| for (let i = 0; i <= 4; i++) { | |
| const y = (i / 4) * h; | |
| ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); | |
| } | |
| // Line chart | |
| ctx.beginPath(); | |
| ctx.strokeStyle = primaryColor; | |
| ctx.lineWidth = 2; | |
| for (let i = 0; i < data.length; i++) { | |
| const x = (i / (data.length - 1)) * w; | |
| const y = h - (data[i] / 100) * h; | |
| if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); | |
| } | |
| ctx.stroke(); | |
| // Gradient fill | |
| ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath(); | |
| const grad = ctx.createLinearGradient(0, 0, 0, h); | |
| grad.addColorStop(0, `rgba(${primaryRGBA}, 0.15)`); | |
| grad.addColorStop(1, `rgba(${primaryRGBA}, 0)`); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| // Dots on data points | |
| ctx.fillStyle = primaryColor; | |
| for (let i = 0; i < data.length; i++) { | |
| const x = (i / (data.length - 1)) * w; | |
| const y = h - (data[i] / 100) * h; | |
| ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |