// 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 weighted 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; } const progressBar = document.querySelector('.hstream-player__progress'); if (!progressBar) return; heatmapContainer = document.createElement('div'); heatmapContainer.className = 'hstream-player__progress-heatmap'; heatmapContainer.setAttribute('aria-hidden', 'true'); heatmapCanvas = document.createElement('canvas'); heatmapCanvas.className = 'hstream-player__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 two passes of a 5-point weighted (Gaussian) moving average. * Near edges falls back to a 3-point average. * Preserves the first and last points. */ function smoothData(data) { if (data.length <= 2) return [...data]; let result = data; for (let pass = 0; pass < 2; pass++) { const smoothed = [result[0]]; for (let i = 1; i < result.length - 1; i++) { if (result.length > 4 && i >= 2 && i <= result.length - 3) { smoothed.push( (result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 + result[i + 1] * 2 + result[i + 2] * 1) / 10 ); } else { smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3); } } smoothed.push(result[result.length - 1]); result = smoothed; } return result; } /** * 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; const paddingX = 1; const paddingY = 2; const drawW = w - paddingX * 2; const drawH = h - paddingY * 2; const n = counts.length; const pts = []; for (let i = 0; i < n; i++) { const x = paddingX + (i / (n - 1 || 1)) * drawW; const ratio = Math.min(counts[i] / maxCount, 1); const y = paddingY + (1 - ratio) * drawH; 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; } }