Spaces:
Running
Running
Download static/script.js from simats0/ARMS-Result: direct link, hf CLI and curl.
- Browser
- Download file 32.9 kB
-
https://huggingface.co/spaces/simats0/ARMS-Result/resolve/main/static/script.js
- Command line
-
hf download hf://spaces/simats0/ARMS-Result/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/simats0/ARMS-Result/resolve/main/static/script.js
32.9 kB
| document.addEventListener('DOMContentLoaded', () => { | |
| const form = document.getElementById('login-form'); | |
| const submitBtn = document.getElementById('submit-btn'); | |
| const btnText = submitBtn.querySelector('.btn-text'); | |
| const loader = submitBtn.querySelector('.loader'); | |
| const loginView = document.getElementById('login-view-container'); | |
| const dashboardView = document.getElementById('dashboard-view'); | |
| const errorMessage = document.getElementById('error-message'); | |
| const errorText = document.getElementById('error-text'); | |
| const logoutBtn = document.getElementById('logout-btn'); | |
| // Pages | |
| const homePage = document.getElementById('home-page'); | |
| const searchPage = document.getElementById('search-page'); | |
| const tabHome = document.getElementById('tab-home'); | |
| const tabSearch = document.getElementById('tab-search'); | |
| // Session state | |
| let currentSessionId = null; | |
| let currentUsername = null; | |
| // ========== TAB SWITCHING ========== | |
| document.getElementById('nav-tabs').addEventListener('click', (e) => { | |
| const tab = e.target.closest('.nav-tab'); | |
| if (!tab) return; | |
| const targetTab = tab.dataset.tab; | |
| // Update active tab styles | |
| document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); | |
| tab.classList.add('active'); | |
| // Show/hide pages | |
| if (targetTab === 'home') { | |
| homePage.classList.remove('hidden'); | |
| searchPage.classList.add('hidden'); | |
| } else if (targetTab === 'search') { | |
| homePage.classList.add('hidden'); | |
| searchPage.classList.remove('hidden'); | |
| } | |
| }); | |
| // ========== LOGIN ========== | |
| form.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const username = document.getElementById('username').value.trim(); | |
| const password = document.getElementById('password').value; | |
| if (!username || !password) return; | |
| setLoading(true); | |
| errorMessage.classList.add('hidden'); | |
| try { | |
| const formData = new FormData(); | |
| formData.append('username', username); | |
| formData.append('password', password); | |
| const response = await fetch('/api/login', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await response.json().catch(() => ({})); | |
| if (!response.ok) { | |
| throw new Error(data.detail || `Server error: ${response.status}`); | |
| } | |
| if (data.success && data['ASP.NET_SessionId']) { | |
| currentSessionId = data['ASP.NET_SessionId']; | |
| currentUsername = username; | |
| loginView.classList.add('hidden'); | |
| dashboardView.classList.remove('hidden'); | |
| // Start fetch marks process | |
| fetchMarks(username, currentSessionId); | |
| } else { | |
| throw new Error("Login succeeded but no session cookie was returned."); | |
| } | |
| } catch (error) { | |
| console.error("Login error:", error); | |
| errorText.textContent = error.message || "Failed to login. Please try again."; | |
| errorMessage.classList.remove('hidden'); | |
| const newError = errorMessage.cloneNode(true); | |
| errorMessage.parentNode.replaceChild(newError, errorMessage); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }); | |
| // ========== LOGOUT ========== | |
| logoutBtn.addEventListener('click', () => { | |
| dashboardView.classList.add('hidden'); | |
| loginView.classList.remove('hidden'); | |
| form.reset(); | |
| currentSessionId = null; | |
| currentUsername = null; | |
| // Reset to home tab | |
| tabHome.click(); | |
| // Clear search results | |
| document.getElementById('search-cards-container').innerHTML = ''; | |
| document.getElementById('search-cards-container').classList.add('hidden'); | |
| document.getElementById('username').focus(); | |
| }); | |
| // ========== FETCH MARKS (HOME) ========== | |
| async function fetchMarks(username, sessionId) { | |
| const loading = document.getElementById('dashboard-loading'); | |
| const container = document.getElementById('cards-container'); | |
| const errorMsg = document.getElementById('dashboard-error'); | |
| const userDisplay = document.getElementById('user-display'); | |
| const circle = document.querySelector('.progress-ring__circle:not(.search-ring-circle)'); | |
| const percentageText = document.getElementById('progress-percentage'); | |
| const progressStatus = document.getElementById('progress-text'); | |
| const radius = circle.r.baseVal.value; | |
| const circumference = radius * 2 * Math.PI; | |
| circle.style.strokeDasharray = `${circumference} ${circumference}`; | |
| circle.style.strokeDashoffset = circumference; | |
| userDisplay.textContent = `${username}`; | |
| loading.classList.remove('hidden'); | |
| container.classList.add('hidden'); | |
| errorMsg.classList.add('hidden'); | |
| function setProgress(percent) { | |
| const offset = circumference - percent / 100 * circumference; | |
| circle.style.strokeDashoffset = offset; | |
| percentageText.textContent = Math.round(percent); | |
| } | |
| setProgress(0); | |
| progressStatus.textContent = 'Connecting to SIMATS...'; | |
| let progress = 0; | |
| const progressInterval = setInterval(() => { | |
| const remaining = 99 - progress; | |
| progress += (Math.random() * 0.1 + 0.05) * remaining; | |
| setProgress(progress); | |
| if (progress > 30 && progress < 60) { | |
| progressStatus.textContent = 'Fetching course enrollments...'; | |
| } else if (progress >= 60 && progress < 85) { | |
| progressStatus.textContent = 'Aggregating detailed mark splits...'; | |
| } else if (progress >= 85) { | |
| progressStatus.textContent = 'Finalizing data format...'; | |
| } | |
| }, 500); | |
| try { | |
| const formData = new FormData(); | |
| formData.append('username', username); | |
| formData.append('session_id', sessionId); | |
| const response = await fetch('/api/fetch-marks', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const data = await response.json().catch(() => ({})); | |
| clearInterval(progressInterval); | |
| if (!response.ok) throw new Error(data.detail || 'Failed to fetch marks'); | |
| setProgress(100); | |
| progressStatus.textContent = 'Data loaded successfully!'; | |
| setTimeout(() => { | |
| try { | |
| loading.classList.add('hidden'); | |
| renderCards(data.data, container); | |
| container.classList.remove('hidden'); | |
| } catch (renderError) { | |
| console.error("Rendering error:", renderError); | |
| loading.classList.add('hidden'); | |
| errorMsg.textContent = "An error occurred while displaying your marks. " + renderError.message; | |
| errorMsg.classList.remove('hidden'); | |
| } | |
| }, 800); | |
| } catch (e) { | |
| clearInterval(progressInterval); | |
| loading.classList.add('hidden'); | |
| errorMsg.textContent = e.message; | |
| errorMsg.classList.remove('hidden'); | |
| container.innerHTML = ''; | |
| } | |
| } | |
| // ========== SEARCH STUDENT ========== | |
| const searchForm = document.getElementById('search-form'); | |
| const searchSubmitBtn = document.getElementById('search-submit-btn'); | |
| searchForm.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const registerNo = document.getElementById('search-register').value.trim(); | |
| if (!registerNo) return; | |
| if (!currentSessionId) { | |
| alert('Session expired. Please log in again.'); | |
| return; | |
| } | |
| const searchBtnText = searchSubmitBtn.querySelector('.btn-text'); | |
| const searchBtnLoader = searchSubmitBtn.querySelector('.loader'); | |
| const searchError = document.getElementById('search-error'); | |
| const searchLoading = document.getElementById('search-loading'); | |
| const searchContainer = document.getElementById('search-cards-container'); | |
| // Setup search loading ring | |
| const searchCircle = document.querySelector('.search-ring-circle'); | |
| const searchPercentText = document.getElementById('search-progress-percentage'); | |
| const searchProgressStatus = document.getElementById('search-progress-text'); | |
| const radius = searchCircle.r.baseVal.value; | |
| const circumference = radius * 2 * Math.PI; | |
| searchCircle.style.strokeDasharray = `${circumference} ${circumference}`; | |
| searchCircle.style.strokeDashoffset = circumference; | |
| function setSearchProgress(percent) { | |
| const offset = circumference - percent / 100 * circumference; | |
| searchCircle.style.strokeDashoffset = offset; | |
| searchPercentText.textContent = Math.round(percent); | |
| } | |
| // UI state | |
| searchSubmitBtn.disabled = true; | |
| searchBtnText.classList.add('hidden'); | |
| searchBtnLoader.classList.remove('hidden'); | |
| searchError.classList.add('hidden'); | |
| searchContainer.classList.add('hidden'); | |
| searchContainer.innerHTML = ''; | |
| searchLoading.classList.remove('hidden'); | |
| setSearchProgress(0); | |
| searchProgressStatus.textContent = 'Searching student records...'; | |
| let progress = 0; | |
| const progressInterval = setInterval(() => { | |
| const remaining = 95 - progress; | |
| // Faster increment for a snappier UI | |
| progress += (Math.random() * 0.12 + 0.05) * remaining; | |
| setSearchProgress(progress); | |
| if (progress > 20 && progress < 50) { | |
| searchProgressStatus.textContent = 'Scanning course databases...'; | |
| } else if (progress >= 50 && progress < 80) { | |
| searchProgressStatus.textContent = 'Matching register number...'; | |
| } else if (progress >= 80) { | |
| searchProgressStatus.textContent = 'Compiling results...'; | |
| } | |
| }, 300); | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 75000); // 75s timeout | |
| try { | |
| const formData = new FormData(); | |
| formData.append('register_no', registerNo); | |
| formData.append('session_id', currentSessionId); | |
| const response = await fetch('/api/search-student', { | |
| method: 'POST', | |
| body: formData, | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); | |
| const data = await response.json().catch(() => ({})); | |
| clearInterval(progressInterval); | |
| if (!response.ok) throw new Error(data.detail || 'Search failed'); | |
| setSearchProgress(100); | |
| searchProgressStatus.textContent = 'Search complete!'; | |
| setTimeout(() => { | |
| searchLoading.classList.add('hidden'); | |
| if (!data.data || data.data.length === 0) { | |
| searchError.innerHTML = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> | |
| <span>No results found for register number: <strong>${registerNo}</strong></span>`; | |
| searchError.classList.remove('hidden'); | |
| return; | |
| } | |
| // Add student badge before results | |
| const badge = document.createElement('div'); | |
| badge.style.textAlign = 'center'; | |
| badge.innerHTML = ` | |
| <div class="search-result-badge"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> | |
| Results for: <strong>${registerNo.toUpperCase()}</strong> · ${data.data.length} course(s) found | |
| </div>`; | |
| searchContainer.appendChild(badge); | |
| renderCards(data.data, searchContainer, true); | |
| searchContainer.classList.remove('hidden'); | |
| }, 600); | |
| } catch (err) { | |
| clearInterval(progressInterval); | |
| searchLoading.classList.add('hidden'); | |
| searchError.innerHTML = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> | |
| <span>${err.message}</span>`; | |
| searchError.classList.remove('hidden'); | |
| } finally { | |
| searchSubmitBtn.disabled = false; | |
| searchBtnText.classList.remove('hidden'); | |
| searchBtnLoader.classList.add('hidden'); | |
| } | |
| }); | |
| // ========== RENDER CARDS (shared between Home & Search) ========== | |
| function renderCards(data, container, isSearch = false) { | |
| if (!data || data.length === 0) { | |
| container.innerHTML = '<div style="color:var(--text-muted); text-align:center; width: 100%; grid-column: 1 / -1; padding: 40px;">No course enrollments found for this period.</div>'; | |
| return; | |
| } | |
| let totalGradePoints = 0; | |
| let totalCredits = 0; | |
| let validCourses = 0; | |
| const gradeValues = { 'S': 10, 'A': 9, 'B': 8, 'C': 7, 'D': 6, 'E': 5 }; | |
| let gradeCounts = { 'S': 0, 'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0 }; | |
| data.forEach((item, index) => { | |
| const card = document.createElement('div'); | |
| card.className = 'course-card'; | |
| card.style.opacity = '0'; | |
| card.animate([ | |
| { opacity: 0, transform: 'translateY(30px) scale(0.95)' }, | |
| { opacity: 1, transform: 'translateY(0) scale(1)' } | |
| ], { | |
| duration: 500, | |
| easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)', | |
| fill: 'forwards', | |
| delay: index * 100 | |
| }); | |
| const c = item.course; | |
| const marks = item.marks; | |
| const error = item.error; | |
| const grade = c.FinalGrade ? c.FinalGrade.trim().toUpperCase() : ''; | |
| if (gradeValues[grade] !== undefined) { | |
| const credit = parseFloat(c.Credit || c.Credits || 1); | |
| totalGradePoints += gradeValues[grade] * credit; | |
| totalCredits += credit; | |
| validCourses++; | |
| if (gradeCounts[grade] !== undefined) { | |
| gradeCounts[grade]++; | |
| } | |
| } | |
| let marksHtml = ''; | |
| let totalObtained = 0; | |
| if (error) { | |
| marksHtml = `<div class="card-error">${error}</div>`; | |
| } else if (marks && marks.length > 0) { | |
| const targetCategories = ["Class Test (IA)", "Research", "Class Practical", "University Theory", "University Practical"]; | |
| marks.forEach(m => { | |
| const category = m.RubricCategory ? m.RubricCategory.trim() : ""; | |
| if (targetCategories.includes(category)) { | |
| const val = parseFloat(m.OrginalConvertedMark); | |
| if (!isNaN(val)) { | |
| totalObtained += val; | |
| } | |
| } | |
| }); | |
| totalObtained = Math.round(totalObtained * 100) / 100; | |
| const list = marks.map(m => { | |
| const isPass = m.IsPassed; | |
| const statusClass = isPass ? 'mark-pass' : 'mark-fail'; | |
| const statusText = isPass ? 'PASS' : 'FAIL'; | |
| return ` | |
| <li class="mark-item"> | |
| <div class="mark-info"> | |
| <span class="mark-name">${m.RubricCategory}</span> | |
| <span class="mark-type">${m.Type}</span> | |
| </div> | |
| <div class="mark-score-container"> | |
| <span class="mark-value">${m.OrginalConvertedMark} <span style="opacity:0.4;font-size:12px;">/ ${m.RubricsMaxMark}</span></span> | |
| <div><span class="mark-status ${statusClass}">${statusText}</span></div> | |
| </div> | |
| </li> | |
| `; | |
| }).join(''); | |
| marksHtml = `<ul class="mark-list">${list}</ul>`; | |
| } else { | |
| marksHtml = `<div style="padding:40px 20px; color:var(--text-muted); text-align:center;">No marks available for this course</div>`; | |
| } | |
| card.innerHTML = ` | |
| <div class="card-header"> | |
| <span class="course-code">${c.CourseCode || '-'}</span> | |
| <h3>${c.CourseName || 'Unknown Course'}</h3> | |
| <div class="card-meta"> | |
| <span class="month-badge">${c.MonthYearValue || '-'}</span> | |
| <div style="display: flex; gap: 8px; align-items: center;"> | |
| ${marks && !error ? `<span class="grade-badge" style="background: rgba(255,255,255,0.15); border-color: rgba(255,255,255,0.3); color: #fff;">Total: ${totalObtained}/500</span>` : ''} | |
| <span class="grade-badge">Grade: ${c.FinalGrade || 'N/A'}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| ${marksHtml} | |
| </div> | |
| `; | |
| container.appendChild(card); | |
| }); | |
| if (validCourses > 0 && totalCredits > 0) { | |
| const cgpa = (totalGradePoints / totalCredits).toFixed(2); | |
| const cgpaCard = document.createElement('div'); | |
| cgpaCard.className = 'course-card'; | |
| cgpaCard.style.opacity = '0'; | |
| cgpaCard.style.gridColumn = '1 / -1'; | |
| cgpaCard.style.background = 'var(--card-bg)'; | |
| cgpaCard.style.border = '2px solid rgba(255, 255, 255, 0.4)'; | |
| cgpaCard.style.textAlign = 'center'; | |
| cgpaCard.style.marginBottom = '20px'; | |
| cgpaCard.animate([ | |
| { opacity: 0, transform: 'translateY(-20px)' }, | |
| { opacity: 1, transform: 'translateY(0)' } | |
| ], { | |
| duration: 500, | |
| easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)', | |
| fill: 'forwards', | |
| delay: 0 | |
| }); | |
| // Use unique IDs for search vs home to avoid conflicts | |
| const prefix = isSearch ? 'search-calc' : 'home-calc'; | |
| cgpaCard.innerHTML = ` | |
| <div style="padding: 40px;"> | |
| <div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 60px; margin-bottom: 30px;"> | |
| <div> | |
| <h2 style="font-size: 18px; font-weight: 500; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 12px; color: var(--text-muted);">Overall CGPA</h2> | |
| <div style="font-size: 64px; font-weight: 700; line-height: 1; letter-spacing: -0.02em; background: var(--primary-grad); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-shadow: 0 10px 30px rgba(255, 255, 255, 0.2);"> | |
| ${cgpa} | |
| </div> | |
| </div> | |
| <div style="width: 1px; height: 80px; background: rgba(255,255,255,0.2); display: block;"></div> | |
| <div> | |
| <h2 style="font-size: 18px; font-weight: 500; letter-spacing: 0.15em; text-transform: uppercase; margin-bottom: 12px; color: var(--text-muted);">Courses Completed</h2> | |
| <div style="font-size: 64px; font-weight: 700; line-height: 1; letter-spacing: -0.02em; color: #fff; text-shadow: 0 10px 30px rgba(255, 255, 255, 0.1);"> | |
| ${validCourses} | |
| </div> | |
| </div> | |
| </div> | |
| <div style="padding-top: 25px; border-top: 1px solid rgba(255,255,255,0.2);"> | |
| <h3 style="font-size: 15px; color: var(--text-main); margin-bottom: 15px; text-transform: uppercase; letter-spacing: 0.05em;">Target CGPA Planner</h3> | |
| <div style="display: flex; gap: 15px; justify-content: center; align-items: center; flex-wrap: wrap;"> | |
| <div style="display: flex; flex-direction: column; align-items: flex-start;"> | |
| <label style="color: var(--text-muted); font-size: 12px; margin-bottom: 4px;">Target CGPA</label> | |
| <input type="number" id="${prefix}-target" value="9.0" step="0.1" min="0" max="10" style="background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.5); color: #fff; padding: 8px 12px; border-radius: 8px; width: 100px; font-family: inherit; outline: none;"> | |
| </div> | |
| <div style="display: flex; flex-direction: column; align-items: flex-start;"> | |
| <label style="color: var(--text-muted); font-size: 12px; margin-bottom: 4px;">Expected Grade</label> | |
| <select id="${prefix}-grade" style="background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.5); color: #fff; padding: 8px 12px; border-radius: 8px; font-family: inherit; outline: none; cursor: pointer;"> | |
| <option value="10">S</option> | |
| <option value="9">A</option> | |
| <option value="8">B</option> | |
| <option value="7">C</option> | |
| <option value="6">D</option> | |
| <option value="5">E</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div id="${prefix}-result" style="margin-top: 20px; font-size: 15px; color: #ffffff; font-weight: 500; min-height: 24px;"></div> | |
| </div> | |
| </div> | |
| `; | |
| container.insertBefore(cgpaCard, container.firstChild); | |
| const updateCalc = () => { | |
| const target = parseFloat(document.getElementById(`${prefix}-target`).value); | |
| const futureGp = parseFloat(document.getElementById(`${prefix}-grade`).value); | |
| const resultDiv = document.getElementById(`${prefix}-result`); | |
| const curr = totalGradePoints / totalCredits; | |
| if (isNaN(target) || isNaN(futureGp)) return; | |
| if (curr >= target) { | |
| resultDiv.innerHTML = `<span style="color: #34d399;">Already has a ${target.toFixed(1)}+ CGPA!</span>`; | |
| return; | |
| } | |
| if (futureGp <= target) { | |
| resultDiv.innerHTML = `<span style="color: #f87171;">Impossible to reach a ${target.toFixed(1)} CGPA by scoring ${futureGp} points or less.</span>`; | |
| return; | |
| } | |
| const selectEl = document.getElementById(`${prefix}-grade`); | |
| const gradeName = selectEl.options[selectEl.selectedIndex].text; | |
| const reqCredits = (target * totalCredits - totalGradePoints) / (futureGp - target); | |
| resultDiv.innerHTML = `Need <strong style="font-size: 20px; color: #fff;">${Math.ceil(reqCredits)}</strong> more credits of <span style="color: #fff; font-weight: bold;">${gradeName}</span> grade to reach a ${target.toFixed(1)} CGPA.`; | |
| }; | |
| document.getElementById(`${prefix}-target`).addEventListener('input', updateCalc); | |
| document.getElementById(`${prefix}-grade`).addEventListener('change', updateCalc); | |
| updateCalc(); | |
| // Grade Distribution Chart | |
| const totalGradesCount = Object.values(gradeCounts).reduce((a, b) => a + b, 0); | |
| if (totalGradesCount > 0 && typeof Chart !== 'undefined') { | |
| const chartId = isSearch ? 'searchChart' : 'performanceChart'; | |
| const graphCard = document.createElement('div'); | |
| graphCard.className = 'course-card'; | |
| graphCard.style.opacity = '0'; | |
| graphCard.style.gridColumn = '1 / -1'; | |
| graphCard.style.background = 'var(--card-bg)'; | |
| graphCard.style.border = '1px solid rgba(255, 255, 255, 0.2)'; | |
| graphCard.style.padding = '30px'; | |
| graphCard.style.marginTop = '20px'; | |
| graphCard.animate([ | |
| { opacity: 0, transform: 'translateY(30px) scale(0.95)' }, | |
| { opacity: 1, transform: 'translateY(0) scale(1)' } | |
| ], { | |
| duration: 500, | |
| easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)', | |
| fill: 'forwards', | |
| delay: (data.length + 1) * 100 | |
| }); | |
| graphCard.innerHTML = ` | |
| <h2 style="font-size: 18px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 20px; color: var(--text-main); text-align: center;">Grade Distribution</h2> | |
| <div style="position: relative; height: 300px; width: 100%;"> | |
| <canvas id="${chartId}"></canvas> | |
| </div> | |
| `; | |
| container.appendChild(graphCard); | |
| const pieLabels = []; | |
| const pieData = []; | |
| const pieColors = []; | |
| const ctx = document.getElementById(chartId).getContext('2d'); | |
| const createGrad = (c1, c2) => { | |
| const g = ctx.createLinearGradient(0, 0, 0, 400); | |
| g.addColorStop(0, c1); | |
| g.addColorStop(1, c2); | |
| return g; | |
| }; | |
| const colorPalette = { | |
| 'S': createGrad('rgba(255, 215, 0, 1)', 'rgba(218, 165, 32, 0.6)'), | |
| 'A': createGrad('rgba(52, 211, 153, 1)', 'rgba(5, 150, 105, 0.6)'), | |
| 'B': createGrad('rgba(96, 165, 250, 1)', 'rgba(37, 99, 235, 0.6)'), | |
| 'C': createGrad('rgba(167, 139, 250, 1)', 'rgba(124, 58, 237, 0.6)'), | |
| 'D': createGrad('rgba(251, 146, 60, 1)', 'rgba(234, 88, 12, 0.6)'), | |
| 'E': createGrad('rgba(248, 113, 113, 1)', 'rgba(220, 38, 38, 0.6)') | |
| }; | |
| for (const g in gradeCounts) { | |
| if (gradeCounts[g] > 0) { | |
| pieLabels.push(g); | |
| pieData.push(gradeCounts[g]); | |
| pieColors.push(colorPalette[g] || '#888'); | |
| } | |
| } | |
| const shadowPlugin = { | |
| id: 'shadowPlugin_' + chartId, | |
| beforeDatasetDraw: (chart) => { | |
| const ctx = chart.ctx; | |
| ctx.save(); | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.4)'; | |
| ctx.shadowBlur = 15; | |
| ctx.shadowOffsetX = 0; | |
| ctx.shadowOffsetY = 8; | |
| }, | |
| afterDatasetDraw: (chart) => { | |
| chart.ctx.restore(); | |
| } | |
| }; | |
| const centerTextPlugin = { | |
| id: 'centerText_' + chartId, | |
| afterDraw: (chart) => { | |
| if (chart.data.datasets.length === 0) return; | |
| const ctx = chart.ctx; | |
| const meta = chart.getDatasetMeta(0); | |
| if (!meta.data || !meta.data.length) return; | |
| const center = meta.data[0]; | |
| const x = center.x; | |
| const y = center.y; | |
| ctx.save(); | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.font = "bold 54px 'Outfit', sans-serif"; | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.shadowColor = 'rgba(255,255,255,0.1)'; | |
| ctx.shadowBlur = 10; | |
| ctx.fillText(totalGradesCount.toString(), x, y - 8); | |
| ctx.font = "400 13px 'Outfit', sans-serif"; | |
| ctx.fillStyle = "#a1a1aa"; | |
| ctx.shadowBlur = 0; | |
| ctx.letterSpacing = "1px"; | |
| ctx.fillText("TOTAL GRADES", x, y + 28); | |
| ctx.restore(); | |
| } | |
| }; | |
| new Chart(ctx, { | |
| type: 'doughnut', | |
| data: { | |
| labels: pieLabels, | |
| datasets: [{ | |
| data: pieData, | |
| backgroundColor: pieColors, | |
| borderColor: 'transparent', | |
| borderWidth: 0, | |
| hoverOffset: 12, | |
| borderRadius: 10, | |
| spacing: 8 | |
| }] | |
| }, | |
| plugins: [shadowPlugin, centerTextPlugin], | |
| options: { | |
| cutout: '78%', | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| layout: { | |
| padding: 20 | |
| }, | |
| animation: { | |
| animateScale: true, | |
| animateRotate: true, | |
| duration: 1500, | |
| easing: 'easeOutQuart' | |
| }, | |
| plugins: { | |
| legend: { | |
| position: 'right', | |
| labels: { | |
| color: '#d1d5db', | |
| font: { size: 14, family: "'Outfit', sans-serif", weight: '500' }, | |
| usePointStyle: true, | |
| pointStyle: 'circle', | |
| padding: 25 | |
| } | |
| }, | |
| tooltip: { | |
| backgroundColor: 'rgba(15, 15, 20, 0.95)', | |
| titleFont: { size: 15, family: "'Outfit', sans-serif", weight: '600' }, | |
| bodyFont: { size: 14, family: "'Outfit', sans-serif" }, | |
| padding: 15, | |
| cornerRadius: 12, | |
| displayColors: true, | |
| usePointStyle: true, | |
| borderColor: 'rgba(255, 255, 255, 0.2)', | |
| borderWidth: 1, | |
| boxPadding: 6 | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| function setLoading(isLoading) { | |
| submitBtn.disabled = isLoading; | |
| if (isLoading) { | |
| btnText.classList.add('hidden'); | |
| loader.classList.remove('hidden'); | |
| } else { | |
| btnText.classList.remove('hidden'); | |
| loader.classList.add('hidden'); | |
| } | |
| } | |
| }); | |