Files
hstream/resources/js/player-heatmap.js
T

241 lines
7.6 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();
}
/**
* Return a heatmap-style color for a given ratio (0..1).
* Low = cool blue/gray, mid = warm amber, high = hot red/pink.
*/
function heatmapColor(ratio) {
// Clamp
const t = Math.max(0, Math.min(1, ratio));
// Four-stop gradient: cool → warm → hot
if (t < 0.33) {
// Cool muted purple-blue → warm amber
const s = t / 0.33;
const r = Math.round(99 + s * (245 - 99));
const g = Math.round(114 + s * (158 - 114));
const b = Math.round(176 + s * (48 - 176));
return `rgba(${r},${g},${b},0.70)`;
} else if (t < 0.66) {
// Warm amber → hot orange
const s = (t - 0.33) / 0.33;
const r = Math.round(245 + s * (255 - 245));
const g = Math.round(158 + s * (107 - 158));
const b = Math.round(48 + s * (35 - 48));
return `rgba(${r},${g},${b},0.75)`;
} else {
// Hot orange → intense red/pink
const s = (t - 0.66) / 0.34;
const r = Math.round(255 + s * (255 - 255));
const g = Math.round(107 + s * (75 - 107));
const b = Math.round(35 + s * (85 - 35));
return `rgba(${r},${g},${b},0.80)`;
}
}
/**
* Remove the heatmap from the DOM.
*/
export function removeHeatmap() {
if (heatmapResizeObserver) {
heatmapResizeObserver.disconnect();
heatmapResizeObserver = null;
}
if (heatmapContainer) {
heatmapContainer.remove();
heatmapContainer = null;
heatmapCanvas = null;
}
}