// Engagement heatmap display // Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track. let heatmapContainer = null; let heatmapCanvas = null; let heatmapResizeObserver = null; /** * Fetch engagement data from the server and render the heatmap. * @param {string} episodeId - The episode ID. * @param {number} duration - Video duration in seconds. */ export async function renderHeatmap(episodeId, duration) { try { const response = await window.axios.get(`/player/engagement/${episodeId}`); const data = response.data; if (!data || Object.keys(data).length === 0) { return; } drawHeatmapCurve(data, duration); } catch (error) { console.error('Failed to load engagement data:', error); } } /** * Draw a smooth area chart on a canvas element above the progress bar. * @param {Object} data - Key-value map of segment -> watch_count. * @param {number} duration - Video duration in seconds. */ function drawHeatmapCurve(data, duration) { const SEGMENT_DURATION = 10; const totalSegments = Math.ceil(duration / SEGMENT_DURATION); // Build raw counts array, filling gaps with 0 const rawCounts = []; for (let i = 0; i < totalSegments; i++) { rawCounts.push(data[i] || 0); } // Apply 3-point moving average to smooth individual spikes const counts = smoothData(rawCounts); const maxCount = Math.max(...counts, 1); // Remove existing heatmap if present if (heatmapContainer) { if (heatmapResizeObserver) heatmapResizeObserver.disconnect(); heatmapContainer.remove(); heatmapCanvas = null; } // Find the progress bar wrapper const progressBar = document.querySelector('.plyr__progress'); if (!progressBar) return; // Create container heatmapContainer = document.createElement('div'); heatmapContainer.className = 'plyr__progress__heatmap'; heatmapContainer.setAttribute('aria-hidden', 'true'); // Create canvas heatmapCanvas = document.createElement('canvas'); heatmapCanvas.className = 'plyr__progress__heatmap-canvas'; heatmapContainer.appendChild(heatmapCanvas); // Insert as first child of the progress bar so it sits behind the scrubber progressBar.insertBefore(heatmapContainer, progressBar.firstChild); // Defer drawing to get container dimensions requestAnimationFrame(() => { requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount)); }); // Redraw on resize heatmapResizeObserver = new ResizeObserver(() => { drawCurve(heatmapCanvas, counts, maxCount); }); heatmapResizeObserver.observe(heatmapContainer); } /** * Apply a 3-point moving average to smooth out jaggedness. * Preserves the first and last points. */ function smoothData(data) { if (data.length <= 2) return [...data]; const smoothed = [data[0]]; // preserve first for (let i = 1; i < data.length - 1; i++) { smoothed.push((data[i - 1] + data[i] + data[i + 1]) / 3); } smoothed.push(data[data.length - 1]); // preserve last return smoothed; } /** * Render vertical bar heatmap directly on the progress bar track. * Each bar represents a time segment; taller bars = more engagement. */ function drawCurve(canvas, counts, maxCount) { const parent = canvas.parentElement; if (!parent) return; const rect = parent.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const dpr = window.devicePixelRatio || 1; const w = rect.width; const h = rect.height; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); if (counts.length === 0 || maxCount === 0) return; // Padding: leave 1px on each side so the curve doesn't clip at edges const paddingX = 1; const paddingY = 1; const drawW = w - paddingX * 2; const drawH = h - paddingY * 2; const baseline = paddingY + drawH / 2; // curve oscillates around the center const amplitude = (drawH / 2) * 0.8; // 80% of half-height to keep inside bounds const n = counts.length; // Build data points: x = horizontal position, y = vertical offset from center const pts = []; for (let i = 0; i < n; i++) { const x = paddingX + (i / (n - 1 || 1)) * drawW; const ratio = counts[i] / maxCount; // ratio 0 = bottom of amplitude range, ratio 1 = top of amplitude range const y = baseline - (ratio - 0.5) * amplitude * 2; pts.push({ x, y }); } if (pts.length < 2) return; // Build the smooth path using quadratic bezier curves through midpoints const path = [{ x: pts[0].x, y: pts[0].y }]; for (let i = 0; i < pts.length - 1; i++) { const midX = (pts[i].x + pts[i + 1].x) / 2; const midY = (pts[i].y + pts[i + 1].y) / 2; path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } }); } path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }); // --- Draw a subtle glow behind the line --- ctx.beginPath(); ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) { const prev = path[i - 1]; const curr = path[i]; if (curr.cp) { ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y); } else { ctx.lineTo(curr.x, curr.y); } } ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; ctx.lineWidth = 3.0; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); // --- Draw the main waveform line --- ctx.beginPath(); ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) { const prev = path[i - 1]; const curr = path[i]; if (curr.cp) { ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y); } else { ctx.lineTo(curr.x, curr.y); } } ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); } /** * Remove the heatmap from the DOM. */ export function removeHeatmap() { if (heatmapResizeObserver) { heatmapResizeObserver.disconnect(); heatmapResizeObserver = null; } if (heatmapContainer) { heatmapContainer.remove(); heatmapContainer = null; heatmapCanvas = null; } }