208 lines
6.5 KiB
JavaScript
208 lines
6.5 KiB
JavaScript
// 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;
|
|
}
|
|
} |