| <!DOCTYPE html>
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <div class="sections-grid">
|
|
|
| <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>
|
|
|
|
|
| <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>
|
|
|
|
|
| <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 (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);
|
|
|
|
|
| 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) + '%' : '—';
|
|
|
|
|
| if (stats.session_info) {
|
|
|
| document.getElementById('ov-session-id').textContent = 'Session Detailed View';
|
|
|
|
|
| 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';
|
|
|
|
|
| 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';
|
|
|
|
|
| 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';
|
|
|
|
|
| document.getElementById('ov-session-duration').textContent = '';
|
| document.getElementById('ov-session-date').textContent = '';
|
|
|
| highlightSessionRow(stats.session_info.id);
|
| } else {
|
|
|
| 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';
|
|
|
|
|
| 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)';
|
|
|
| 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;
|
|
|
|
|
| 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();
|
|
|
|
|
| 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;
|
|
|
|
|
| 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)';
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
| 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();
|
|
|
|
|
| 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();
|
|
|
|
|
| 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> |