diff --git "a/src/views/InstructorView.js" "b/src/views/InstructorView.js" --- "a/src/views/InstructorView.js" +++ "b/src/views/InstructorView.js" @@ -740,20 +740,22 @@ const checkPermissions = (instructor) => { currentInstructor = instructor; // 1. Create Room Permission - if (instructor.permissions?.includes('create_room')) { + if (instructor.permissions?.includes('create_room') && createBtn) { createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed'); createBtn.disabled = false; - } else { + } else if (createBtn) { createBtn.classList.add('opacity-50', 'cursor-not-allowed'); createBtn.disabled = true; createBtn.title = "無此權限"; } // 2. Add Question Permission (Admin Button) - if (instructor.permissions?.includes('add_question')) { - navAdminBtn.classList.remove('hidden'); - } else { - navAdminBtn.classList.add('hidden'); + if (navAdminBtn) { + if (instructor.permissions?.includes('add_question')) { + navAdminBtn.classList.remove('hidden'); + } else { + navAdminBtn.classList.add('hidden'); + } } // 3. Manage Instructors Permission @@ -1252,7 +1254,7 @@ import("../services/firebase.js").then(async ({ auth }) => { if (instructorData) { console.log("Hiding Modal and Setting Permissions..."); - authModal.classList.add('hidden'); + if (authModal) authModal.classList.add('hidden'); checkPermissions(instructorData); // Auto-Restore Room View if exists @@ -1292,8 +1294,10 @@ import("../services/firebase.js").then(async ({ auth }) => { } } catch (e) { console.error("Permission Check Failed:", e); - authErrorMsg.textContent = "權限檢查失敗: " + e.message; - authErrorMsg.classList.remove('hidden'); + if (authErrorMsg) { + authErrorMsg.textContent = "權限檢查失敗: " + e.message; + authErrorMsg.classList.remove('hidden'); + } } } else { authModal.classList.remove('hidden'); @@ -1361,146 +1365,147 @@ window.removeInst = async (email) => { } }; // Snapshot Logic -snapshotBtn.addEventListener('click', async () => { - if (isSnapshotting || typeof htmlToImage === 'undefined') { - if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試"); - return; - } - isSnapshotting = true; - - const overlay = document.getElementById('snapshot-overlay'); - const countEl = document.getElementById('countdown-number'); - const container = document.getElementById('group-photo-container'); - const modal = document.getElementById('group-photo-modal'); - - // Close button hide - const closeBtn = modal.querySelector('button'); - if (closeBtn) closeBtn.style.opacity = '0'; - snapshotBtn.style.opacity = '0'; - - overlay.classList.remove('hidden'); - overlay.classList.add('flex'); - - // Countdown Sequence - const runCountdown = (num) => new Promise(resolve => { - countEl.textContent = num; - countEl.style.transform = 'scale(1.5)'; - countEl.style.opacity = '1'; - - // Animation reset - requestAnimationFrame(() => { - countEl.style.transition = 'all 0.5s ease-out'; - countEl.style.transform = 'scale(1)'; - countEl.style.opacity = '0.5'; - setTimeout(resolve, 1000); +if (snapshotBtn) { + snapshotBtn.addEventListener('click', async () => { + if (isSnapshotting || typeof htmlToImage === 'undefined') { + if (typeof htmlToImage === 'undefined') alert("截圖元件尚未載入,請稍候再試"); + return; + } + isSnapshotting = true; + + const overlay = document.getElementById('snapshot-overlay'); + const countEl = document.getElementById('countdown-number'); + const container = document.getElementById('group-photo-container'); + const modal = document.getElementById('group-photo-modal'); + + // Close button hide + const closeBtn = modal.querySelector('button'); + if (closeBtn) closeBtn.style.opacity = '0'; + snapshotBtn.style.opacity = '0'; + + overlay.classList.remove('hidden'); + overlay.classList.add('flex'); + + // Countdown Sequence + const runCountdown = (num) => new Promise(resolve => { + countEl.textContent = num; + countEl.style.transform = 'scale(1.5)'; + countEl.style.opacity = '1'; + + // Animation reset + requestAnimationFrame(() => { + countEl.style.transition = 'all 0.5s ease-out'; + countEl.style.transform = 'scale(1)'; + countEl.style.opacity = '0.5'; + setTimeout(resolve, 1000); + }); }); - }); - await runCountdown(3); - await runCountdown(2); - await runCountdown(1); - - // Action! - countEl.textContent = ''; - overlay.classList.add('hidden'); - - // 1. Emojis Explosion - const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥']; - const cards = container.querySelectorAll('.group\\/card'); - - cards.forEach(card => { - // Find the monster image container - const imgContainer = card.querySelector('.monster-img-container'); - if (!imgContainer) return; - - // Random Emoji - const emoji = emojis[Math.floor(Math.random() * emojis.length)]; - const emojiEl = document.createElement('div'); - emojiEl.textContent = emoji; - // Position: Top-Right of the *Image*, slightly overlapping - emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12'; - emojiEl.style.animationDuration = '0.6s'; - imgContainer.appendChild(emojiEl); - - // Remove after 3s - setTimeout(() => emojiEl.remove(), 3000); - }); + await runCountdown(3); + await runCountdown(2); + await runCountdown(1); + + // Action! + countEl.textContent = ''; + overlay.classList.add('hidden'); + + // 1. Emojis Explosion + const emojis = ['🤘', '✌️', '👍', '🫶', '😎', '🔥']; + const cards = container.querySelectorAll('.group\\/card'); + + cards.forEach(card => { + // Find the monster image container + const imgContainer = card.querySelector('.monster-img-container'); + if (!imgContainer) return; + + // Random Emoji + const emoji = emojis[Math.floor(Math.random() * emojis.length)]; + const emojiEl = document.createElement('div'); + emojiEl.textContent = emoji; + // Position: Top-Right of the *Image*, slightly overlapping + emojiEl.className = 'absolute -top-2 -right-2 text-2xl animate-bounce z-50 drop-shadow-md transform rotate-12'; + emojiEl.style.animationDuration = '0.6s'; + imgContainer.appendChild(emojiEl); + + // Remove after 3s + setTimeout(() => emojiEl.remove(), 3000); + }); - // 2. Capture using html-to-image - setTimeout(async () => { - try { - // Flash Effect - const flash = document.createElement('div'); - flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none'; - document.body.appendChild(flash); - setTimeout(() => flash.style.opacity = '0', 50); - setTimeout(() => flash.remove(), 300); - - // Use htmlToImage.toPng - const dataUrl = await htmlToImage.toPng(container, { - backgroundColor: '#111827', - pixelRatio: 2, - cacheBust: true, - }); + // 2. Capture using html-to-image + setTimeout(async () => { + try { + // Flash Effect + const flash = document.createElement('div'); + flash.className = 'fixed inset-0 bg-white z-[100] transition-opacity duration-300 pointer-events-none'; + document.body.appendChild(flash); + setTimeout(() => flash.style.opacity = '0', 50); + setTimeout(() => flash.remove(), 300); + + // Use htmlToImage.toPng + const dataUrl = await htmlToImage.toPng(container, { + backgroundColor: '#111827', + pixelRatio: 2, + cacheBust: true, + }); - // Download - const link = document.createElement('a'); - const dateStr = new Date().toISOString().slice(0, 10); - link.download = `VIBE_Class_Photo_${dateStr}.png`; - link.href = dataUrl; - link.click(); + // Download + const link = document.createElement('a'); + const dateStr = new Date().toISOString().slice(0, 10); + link.download = `VIBE_Class_Photo_${dateStr}.png`; + link.href = dataUrl; + link.click(); - } catch (e) { - console.error("Snapshot failed:", e); - alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message); - } finally { - // Restore UI - if (closeBtn) closeBtn.style.opacity = '1'; - snapshotBtn.style.opacity = '1'; - isSnapshotting = false; - } - }, 600); // Slight delay for emojis to appear -}); + } catch (e) { + console.error("Snapshot failed:", e); + alert("截圖失敗 (請嘗試手動截圖/PrtSc)\n原因: " + e.message); + } finally { + // Restore UI + if (closeBtn) closeBtn.style.opacity = '1'; + snapshotBtn.style.opacity = '1'; + isSnapshotting = false; + } + }, 600); // Slight delay for emojis to appear + }); -// Group Photo Logic -groupPhotoBtn.addEventListener('click', () => { - const modal = document.getElementById('group-photo-modal'); - const container = document.getElementById('group-photo-container'); - const dateEl = document.getElementById('photo-date'); + // Group Photo Logic + groupPhotoBtn.addEventListener('click', () => { + const modal = document.getElementById('group-photo-modal'); + const container = document.getElementById('group-photo-container'); + const dateEl = document.getElementById('photo-date'); - // Update Date - const now = new Date(); - dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `; + // Update Date + const now = new Date(); + dateEl.textContent = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} `; - // Get saved name - const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)'; + // Get saved name + const savedName = localStorage.getItem('vibecoding_instructor_name') || '講師 (Instructor)'; - container.innerHTML = ''; + container.innerHTML = ''; - // 1. Container for Relative Positioning with Custom Background - const relativeContainer = document.createElement('div'); - relativeContainer.className = 'relative w-full h-[600px] md:h-[700px] overflow-hidden rounded-3xl border border-gray-700/30 shadow-2xl flex items-center justify-center bg-cover bg-center'; - relativeContainer.style.backgroundImage = "url('assets/photobg.png')"; - container.appendChild(relativeContainer); + // 1. Container for Relative Positioning with Custom Background + const relativeContainer = document.createElement('div'); + relativeContainer.className = 'relative w-full h-[600px] md:h-[700px] overflow-hidden rounded-3xl border border-gray-700/30 shadow-2xl flex items-center justify-center bg-cover bg-center'; + relativeContainer.style.backgroundImage = "url('assets/photobg.png')"; + container.appendChild(relativeContainer); - // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop) - const watermark = document.createElement('div'); - watermark.className = 'absolute bottom-2 right-2 md:bottom-4 md:right-4 z-[60] bg-black/70 backdrop-blur-sm rounded-lg px-4 py-2 pointer-events-none select-none border border-white/10 shadow-lg'; + // Watermark (Bottom Right, High Z-Index, Gradient Text, Dark Backdrop) + const watermark = document.createElement('div'); + watermark.className = 'absolute bottom-2 right-2 md:bottom-4 md:right-4 z-[60] bg-black/70 backdrop-blur-sm rounded-lg px-4 py-2 pointer-events-none select-none border border-white/10 shadow-lg'; - const d = new Date(); - const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `; + const d = new Date(); + const dateStr = `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} `; - watermark.innerHTML = ` + watermark.innerHTML = ` ${dateStr} VibeCoding 怪獸成長營 `; - relativeContainer.appendChild(watermark); + relativeContainer.appendChild(watermark); - // 2. Instructor Section (Absolute Center) - const instructorSection = document.createElement('div'); - instructorSection.className = 'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center z-20 group cursor-pointer'; - instructorSection.innerHTML = ` + // 2. Instructor Section (Absolute Center) + const instructorSection = document.createElement('div'); + instructorSection.className = 'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center z-20 group cursor-pointer'; + instructorSection.innerHTML = `
${item.title}
@@ -2290,222 +2295,222 @@ window.openComparisonView = (items, initialAnonymous = false) => { `; - grid.appendChild(col); - - // If blurred, remove blur after animation purely for effect, or keep? - // User intention "Hidden Name" usually means "Replaced by generic name". - // The blur effect in toggle logic was transient. - // If we want persistent anonymity, just "學員" is enough. - // The existing toggle logic adds 'blur-sm' then removes it in 300ms. - // We should replicate that effect if we want consistency, or just skip blur on init. - if (isAnonymous) { - const el = col.querySelector('.comparison-author'); - setTimeout(() => el.classList.remove('blur-sm'), 300); - } - }); - - document.getElementById('prompt-list-modal').classList.add('hidden'); - modal.classList.remove('hidden'); - - // Init Canvas (Phase 3) - setTimeout(setupCanvas, 100); -}; + grid.appendChild(col); + + // If blurred, remove blur after animation purely for effect, or keep? + // User intention "Hidden Name" usually means "Replaced by generic name". + // The blur effect in toggle logic was transient. + // If we want persistent anonymity, just "學員" is enough. + // The existing toggle logic adds 'blur-sm' then removes it in 300ms. + // We should replicate that effect if we want consistency, or just skip blur on init. + if (isAnonymous) { + const el = col.querySelector('.comparison-author'); + setTimeout(() => el.classList.remove('blur-sm'), 300); + } + }); -window.closeComparison = () => { - document.getElementById('comparison-modal').classList.add('hidden'); - clearCanvas(); -}; + document.getElementById('prompt-list-modal').classList.add('hidden'); + modal.classList.remove('hidden'); -// --- Phase 3 & 6: Annotation Tools --- -let canvas, ctx; -let isDrawing = false; -let currentPenColor = '#ef4444'; // Red default -let currentLineWidth = 3; -let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser) - -window.setupCanvas = () => { - canvas = document.getElementById('annotation-canvas'); - const container = document.getElementById('comparison-container'); - if (!canvas || !container) return; - - ctx = canvas.getContext('2d'); - - // Resize - const resize = () => { - canvas.width = container.clientWidth; - canvas.height = container.clientHeight; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.strokeStyle = currentPenColor; - ctx.lineWidth = currentLineWidth; - ctx.globalCompositeOperation = currentMode; + // Init Canvas (Phase 3) + setTimeout(setupCanvas, 100); }; - resize(); - window.addEventListener('resize', resize); - - // Init Size UI & Cursor - updateSizeBtnUI(); - updateCursorStyle(); - - // Cursor Logic - const cursor = document.getElementById('tool-cursor'); - - canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden')); - canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden')); - canvas.addEventListener('mousemove', (e) => { - const { x, y } = getPos(e); - cursor.style.left = `${x}px`; - cursor.style.top = `${y}px`; - }); - // Drawing Events - const start = (e) => { - isDrawing = true; - ctx.beginPath(); + window.closeComparison = () => { + document.getElementById('comparison-modal').classList.add('hidden'); + clearCanvas(); + }; - // Re-apply settings (state might change) - ctx.globalCompositeOperation = currentMode; - ctx.strokeStyle = currentPenColor; - ctx.lineWidth = currentLineWidth; + // --- Phase 3 & 6: Annotation Tools --- + let canvas, ctx; + let isDrawing = false; + let currentPenColor = '#ef4444'; // Red default + let currentLineWidth = 3; + let currentMode = 'source-over'; // 'source-over' (Pen) or 'destination-out' (Eraser) + + window.setupCanvas = () => { + canvas = document.getElementById('annotation-canvas'); + const container = document.getElementById('comparison-container'); + if (!canvas || !container) return; + + ctx = canvas.getContext('2d'); + + // Resize + const resize = () => { + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.strokeStyle = currentPenColor; + ctx.lineWidth = currentLineWidth; + ctx.globalCompositeOperation = currentMode; + }; + resize(); + window.addEventListener('resize', resize); + + // Init Size UI & Cursor + updateSizeBtnUI(); + updateCursorStyle(); + + // Cursor Logic + const cursor = document.getElementById('tool-cursor'); + + canvas.addEventListener('mouseenter', () => cursor.classList.remove('hidden')); + canvas.addEventListener('mouseleave', () => cursor.classList.add('hidden')); + canvas.addEventListener('mousemove', (e) => { + const { x, y } = getPos(e); + cursor.style.left = `${x}px`; + cursor.style.top = `${y}px`; + }); - const { x, y } = getPos(e); - ctx.moveTo(x, y); + // Drawing Events + const start = (e) => { + isDrawing = true; + ctx.beginPath(); + + // Re-apply settings (state might change) + ctx.globalCompositeOperation = currentMode; + ctx.strokeStyle = currentPenColor; + ctx.lineWidth = currentLineWidth; + + const { x, y } = getPos(e); + ctx.moveTo(x, y); + }; + + const move = (e) => { + if (!isDrawing) return; + const { x, y } = getPos(e); + ctx.lineTo(x, y); + ctx.stroke(); + }; + + const end = () => { + isDrawing = false; + }; + + canvas.onmousedown = start; + canvas.onmousemove = move; + canvas.onmouseup = end; + canvas.onmouseleave = end; + + // Touch support + canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); }; + canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); }; + canvas.ontouchend = (e) => { e.preventDefault(); end(); }; }; - const move = (e) => { - if (!isDrawing) return; - const { x, y } = getPos(e); - ctx.lineTo(x, y); - ctx.stroke(); - }; + function getPos(e) { + const rect = canvas.getBoundingClientRect(); + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } - const end = () => { - isDrawing = false; + // Unified Tool Handler + window.setPenTool = (tool, color, btn) => { + // UI Update + document.querySelectorAll('.annotation-tool').forEach(b => { + b.classList.remove('ring-white'); + b.classList.add('ring-transparent'); + }); + btn.classList.remove('ring-transparent'); + btn.classList.add('ring-white'); + + if (tool === 'eraser') { + currentMode = 'destination-out'; + // Force larger eraser size (e.g., 3x current size or fixed large) + // We'll multiply current selected size by 4 for better UX + const multiplier = 4; + // Store original explicitly if needed, but currentLineWidth is global. + // We should dynamically adjust context lineWidth during draw, or just hack it here. + // Hack: If we change currentLineWidth here, the UI size buttons might look wrong. + // Better: Update cursor style only? No, actual draw needs it. + // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily? + // Simpler: Just change it. When user clicks size button, it resets. + // But if user clicks Pen back? We need to restore. + // Let's rely on setPenTool being called with color. + // When "Pen" is clicked, we usually don't call setPenTool with a saved size... + // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen. + // We need to change how draw() uses the width. + // BUT, since we don't want to touch draw() deep inside: + // We will hijack currentLineWidth. + if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth; + currentLineWidth = window.savedPenWidth * 4; + } else { + currentMode = 'source-over'; + currentPenColor = color; + // Restore pen width + if (window.savedPenWidth) { + currentLineWidth = window.savedPenWidth; + window.savedPenWidth = null; + } + } + updateCursorStyle(); }; - canvas.onmousedown = start; - canvas.onmousemove = move; - canvas.onmouseup = end; - canvas.onmouseleave = end; + // Size Handler + window.setPenSize = (size, btn) => { + currentLineWidth = size; + updateSizeBtnUI(); + updateCursorStyle(); + }; - // Touch support - canvas.ontouchstart = (e) => { e.preventDefault(); start(e.touches[0]); }; - canvas.ontouchmove = (e) => { e.preventDefault(); move(e.touches[0]); }; - canvas.ontouchend = (e) => { e.preventDefault(); end(); }; -}; + function updateCursorStyle() { + const cursor = document.getElementById('tool-cursor'); + if (!cursor) return; -function getPos(e) { - const rect = canvas.getBoundingClientRect(); - return { - x: e.clientX - rect.left, - y: e.clientY - rect.top - }; -} + // Size + cursor.style.width = `${currentLineWidth}px`; + cursor.style.height = `${currentLineWidth}px`; -// Unified Tool Handler -window.setPenTool = (tool, color, btn) => { - // UI Update - document.querySelectorAll('.annotation-tool').forEach(b => { - b.classList.remove('ring-white'); - b.classList.add('ring-transparent'); - }); - btn.classList.remove('ring-transparent'); - btn.classList.add('ring-white'); - - if (tool === 'eraser') { - currentMode = 'destination-out'; - // Force larger eraser size (e.g., 3x current size or fixed large) - // We'll multiply current selected size by 4 for better UX - const multiplier = 4; - // Store original explicitly if needed, but currentLineWidth is global. - // We should dynamically adjust context lineWidth during draw, or just hack it here. - // Hack: If we change currentLineWidth here, the UI size buttons might look wrong. - // Better: Update cursor style only? No, actual draw needs it. - // Let's set a separate 'actualWidth' used in draw, OR just change currentLineWidth temporarily? - // Simpler: Just change it. When user clicks size button, it resets. - // But if user clicks Pen back? We need to restore. - // Let's rely on setPenTool being called with color. - // When "Pen" is clicked, we usually don't call setPenTool with a saved size... - // Actually, let's just use a large default for eraser, and keep currentLineWidth for Pen. - // We need to change how draw() uses the width. - // BUT, since we don't want to touch draw() deep inside: - // We will hijack currentLineWidth. - if (!window.savedPenWidth) window.savedPenWidth = currentLineWidth; - currentLineWidth = window.savedPenWidth * 4; - } else { - currentMode = 'source-over'; - currentPenColor = color; - // Restore pen width - if (window.savedPenWidth) { - currentLineWidth = window.savedPenWidth; - window.savedPenWidth = null; + // Color + if (currentMode === 'destination-out') { + // Eraser: White solid + cursor.style.backgroundColor = 'white'; + cursor.style.borderColor = '#999'; + } else { + // Pen: Tool color + cursor.style.backgroundColor = currentPenColor; + cursor.style.borderColor = 'rgba(255,255,255,0.8)'; } } - updateCursorStyle(); -}; -// Size Handler -window.setPenSize = (size, btn) => { - currentLineWidth = size; - updateSizeBtnUI(); - updateCursorStyle(); -}; - -function updateCursorStyle() { - const cursor = document.getElementById('tool-cursor'); - if (!cursor) return; - - // Size - cursor.style.width = `${currentLineWidth}px`; - cursor.style.height = `${currentLineWidth}px`; - - // Color - if (currentMode === 'destination-out') { - // Eraser: White solid - cursor.style.backgroundColor = 'white'; - cursor.style.borderColor = '#999'; - } else { - // Pen: Tool color - cursor.style.backgroundColor = currentPenColor; - cursor.style.borderColor = 'rgba(255,255,255,0.8)'; + function updateSizeBtnUI() { + document.querySelectorAll('.size-btn').forEach(b => { + if (parseInt(b.dataset.size) === currentLineWidth) { + b.classList.add('bg-gray-600', 'text-white'); + b.classList.remove('text-gray-400', 'hover:bg-gray-700'); + } else { + b.classList.remove('bg-gray-600', 'text-white'); + b.classList.add('text-gray-400', 'hover:bg-gray-700'); + } + }); } -} -function updateSizeBtnUI() { - document.querySelectorAll('.size-btn').forEach(b => { - if (parseInt(b.dataset.size) === currentLineWidth) { - b.classList.add('bg-gray-600', 'text-white'); - b.classList.remove('text-gray-400', 'hover:bg-gray-700'); - } else { - b.classList.remove('bg-gray-600', 'text-white'); - b.classList.add('text-gray-400', 'hover:bg-gray-700'); + window.clearCanvas = () => { + if (canvas && ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); } - }); -} + }; -window.clearCanvas = () => { - if (canvas && ctx) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - } -}; + /** + * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students) + */ + function renderTransposedHeatmap(students) { + const thead = document.getElementById('heatmap-header'); + const tbody = document.getElementById('heatmap-body'); -/** - * Renders the Transposed Heatmap (Rows=Challenges, Cols=Students) - */ -function renderTransposedHeatmap(students) { - const thead = document.getElementById('heatmap-header'); - const tbody = document.getElementById('heatmap-body'); - - if (students.length === 0) { - thead.innerHTML = '