294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { Canvas, loadImage, Image } from "canvas";
|
|
import { ReplayData } from "../types/replay";
|
|
import * as path from "path";
|
|
|
|
type ViewMode = 'top' | 'side' | 'front';
|
|
|
|
interface HeatmapConfig {
|
|
width: number;
|
|
height: number;
|
|
worldSize: number;
|
|
sensitivity: number;
|
|
blurAmount: number;
|
|
viewMode: ViewMode;
|
|
backgroundImage?: string;
|
|
preloadedBackground?: Image;
|
|
}
|
|
|
|
const defaultConfig: HeatmapConfig = {
|
|
width: 100,
|
|
height: 100,
|
|
worldSize: 500,
|
|
sensitivity: 0.7,
|
|
blurAmount: 3,
|
|
viewMode: 'top'
|
|
};
|
|
|
|
export const drawHeatmap = (canvas: Canvas, replayData: ReplayData, config: Partial<HeatmapConfig> = {}) => {
|
|
const finalConfig = { ...defaultConfig, ...config };
|
|
|
|
// Initialize heatmap array
|
|
const heatmap: number[][] = Array(finalConfig.height).fill(null).map(() => Array(finalConfig.width).fill(0));
|
|
|
|
let good = 0, bad = 0;
|
|
|
|
// Process all player positions
|
|
for (const player of replayData.players) {
|
|
for (const snapshot of player.snapshots) {
|
|
const pos = snapshot.position;
|
|
|
|
// Convert world coordinates to heatmap coordinates based on view mode
|
|
let x: number, y: number;
|
|
|
|
switch (finalConfig.viewMode) {
|
|
case 'top':
|
|
// Top view: X,Z -> heatmap X,Y
|
|
x = Math.floor((pos.x / finalConfig.worldSize + 0.5) * finalConfig.width);
|
|
y = Math.floor((pos.z / finalConfig.worldSize + 0.5) * finalConfig.height);
|
|
break;
|
|
case 'side':
|
|
// Side view: Z,Y -> heatmap X,Y
|
|
x = Math.floor((pos.z / finalConfig.worldSize + 0.5) * finalConfig.width);
|
|
y = Math.floor((pos.y / finalConfig.worldSize + 0.5) * finalConfig.height);
|
|
break;
|
|
case 'front':
|
|
// Front view: X,Y -> heatmap X,Y
|
|
x = Math.floor((pos.x / finalConfig.worldSize + 0.5) * finalConfig.width);
|
|
y = Math.floor((pos.y / finalConfig.worldSize + 0.5) * finalConfig.height);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown view mode: ${finalConfig.viewMode}`);
|
|
}
|
|
|
|
if (x >= 0 && x < finalConfig.width && y >= 0 && y < finalConfig.height) {
|
|
// Apply blur effect for smoother heatmap areas
|
|
const blurRadius = finalConfig.blurAmount;
|
|
for (let dx = -blurRadius; dx <= blurRadius; dx++) {
|
|
for (let dy = -blurRadius; dy <= blurRadius; dy++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
|
|
if (nx >= 0 && nx < finalConfig.width && ny >= 0 && ny < finalConfig.height) {
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
// Gaussian-like falloff
|
|
const weight = Math.exp(-distance * distance / (2 * blurRadius * blurRadius));
|
|
heatmap[ny][nx] += Math.round(weight * 5);
|
|
}
|
|
}
|
|
}
|
|
good++;
|
|
} else {
|
|
bad++;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Good: ${good}, Bad: ${bad}`);
|
|
|
|
// Find maximum count for normalization
|
|
let maxCount = 0;
|
|
for (let y = 0; y < finalConfig.height; y++) {
|
|
for (let x = 0; x < finalConfig.width; x++) {
|
|
maxCount = Math.max(maxCount, heatmap[y][x]);
|
|
}
|
|
}
|
|
|
|
// Create image data
|
|
const ctx = canvas.getContext("2d");
|
|
const imageData = ctx.createImageData(finalConfig.width, finalConfig.height);
|
|
|
|
// Generate heatmap colors
|
|
for (let y = 0; y < finalConfig.height; y++) {
|
|
for (let x = 0; x < finalConfig.width; x++) {
|
|
let value = heatmap[y][x] / maxCount;
|
|
|
|
// Apply power curve to increase sensitivity to lower values
|
|
value = Math.pow(value, finalConfig.sensitivity);
|
|
|
|
const color = getHeatmapColor(value);
|
|
const index = (y * finalConfig.width + x) * 4;
|
|
|
|
imageData.data[index] = color.r; // Red
|
|
imageData.data[index + 1] = color.g; // Green
|
|
imageData.data[index + 2] = color.b; // Blue
|
|
imageData.data[index + 3] = 255; // Alpha
|
|
}
|
|
}
|
|
|
|
// Scale the image to canvas size
|
|
const tempCanvas = canvas.constructor as any;
|
|
const tempCtx = new tempCanvas(finalConfig.width, finalConfig.height).getContext("2d");
|
|
tempCtx.putImageData(imageData, 0, 0);
|
|
|
|
// Clear main canvas and draw scaled heatmap
|
|
ctx.fillStyle = "white";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(tempCtx.canvas, 0, 0, canvas.width, canvas.height);
|
|
};
|
|
|
|
function getHeatmapColor(value: number): { r: number; g: number; b: number } {
|
|
// Blue -> Green -> Yellow -> Red gradient
|
|
if (value < 0.33) {
|
|
// Blue to Green (0.0 to 0.33)
|
|
const t = value / 0.33;
|
|
return {
|
|
r: Math.round(lerp(0, 0, t) * 255),
|
|
g: Math.round(lerp(0, 1, t) * 255),
|
|
b: Math.round(lerp(1, 0, t) * 255)
|
|
};
|
|
} else if (value < 0.66) {
|
|
// Green to Yellow (0.33 to 0.66)
|
|
const t = (value - 0.33) / 0.33;
|
|
return {
|
|
r: Math.round(lerp(0, 1, t) * 255),
|
|
g: Math.round(lerp(1, 1, t) * 255),
|
|
b: Math.round(lerp(0, 0, t) * 255)
|
|
};
|
|
} else {
|
|
// Yellow to Red (0.66 to 1.0)
|
|
const t = (value - 0.66) / 0.34;
|
|
return {
|
|
r: Math.round(lerp(1, 1, t) * 255),
|
|
g: Math.round(lerp(1, 0, t) * 255),
|
|
b: Math.round(lerp(0, 0, t) * 255)
|
|
};
|
|
}
|
|
}
|
|
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
export const drawTimeBasedHeatmap = async (canvas: Canvas, replayData: ReplayData, maxTime: number, config: Partial<HeatmapConfig> = {}) => {
|
|
const finalConfig = { ...defaultConfig, ...config };
|
|
|
|
// Initialize heatmap array
|
|
const heatmap: number[][] = Array(finalConfig.height).fill(null).map(() => Array(finalConfig.width).fill(0));
|
|
|
|
let good = 0, bad = 0;
|
|
|
|
// Process all player positions up to maxTime
|
|
for (const player of replayData.players) {
|
|
for (const snapshot of player.snapshots) {
|
|
// Only include positions up to the specified time
|
|
if (snapshot.time > maxTime) {
|
|
continue;
|
|
}
|
|
|
|
const pos = snapshot.position;
|
|
|
|
// Convert world coordinates to heatmap coordinates based on view mode
|
|
let x: number, y: number;
|
|
|
|
switch (finalConfig.viewMode) {
|
|
case 'top':
|
|
// Top view: X,Z -> heatmap X,Y
|
|
x = Math.floor((pos.x / finalConfig.worldSize + 0.5) * finalConfig.width);
|
|
y = Math.floor((pos.z / finalConfig.worldSize + 0.5) * finalConfig.height);
|
|
break;
|
|
case 'side':
|
|
// Side view: Z,Y -> heatmap X,Y
|
|
x = Math.floor((pos.z / finalConfig.worldSize + 0.5) * finalConfig.width);
|
|
y = Math.floor((pos.y / finalConfig.worldSize + 0.5) * finalConfig.height);
|
|
break;
|
|
case 'front':
|
|
// Front view: X,Y -> heatmap X,Y
|
|
x = Math.floor((pos.x / finalConfig.worldSize + 0.5) * finalConfig.width);
|
|
y = Math.floor((pos.y / finalConfig.worldSize + 0.5) * finalConfig.height);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown view mode: ${finalConfig.viewMode}`);
|
|
}
|
|
|
|
if (x >= 0 && x < finalConfig.width && y >= 0 && y < finalConfig.height) {
|
|
// Apply blur effect for smoother heatmap areas
|
|
const blurRadius = finalConfig.blurAmount;
|
|
for (let dx = -blurRadius; dx <= blurRadius; dx++) {
|
|
for (let dy = -blurRadius; dy <= blurRadius; dy++) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
|
|
if (nx >= 0 && nx < finalConfig.width && ny >= 0 && ny < finalConfig.height) {
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
// Gaussian-like falloff
|
|
const weight = Math.exp(-distance * distance / (2 * blurRadius * blurRadius));
|
|
heatmap[ny][nx] += Math.round(weight * 5);
|
|
}
|
|
}
|
|
}
|
|
good++;
|
|
} else {
|
|
bad++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// console.log(`Time ${maxTime}s - Good: ${good}, Bad: ${bad}`);
|
|
|
|
// Find maximum count for normalization
|
|
let maxCount = 0;
|
|
for (let y = 0; y < finalConfig.height; y++) {
|
|
for (let x = 0; x < finalConfig.width; x++) {
|
|
maxCount = Math.max(maxCount, heatmap[y][x]);
|
|
}
|
|
}
|
|
|
|
// Create image data
|
|
const ctx = canvas.getContext("2d");
|
|
const imageData = ctx.createImageData(finalConfig.width, finalConfig.height);
|
|
|
|
// Generate heatmap colors with transparency
|
|
for (let y = 0; y < finalConfig.height; y++) {
|
|
for (let x = 0; x < finalConfig.width; x++) {
|
|
let value = maxCount > 0 ? heatmap[y][x] / maxCount : 0;
|
|
|
|
// Apply power curve to increase sensitivity to lower values
|
|
value = Math.pow(value, finalConfig.sensitivity);
|
|
|
|
const color = getHeatmapColor(value);
|
|
const index = (y * finalConfig.width + x) * 4;
|
|
|
|
imageData.data[index] = color.r; // Red
|
|
imageData.data[index + 1] = color.g; // Green
|
|
imageData.data[index + 2] = color.b; // Blue
|
|
// Make heatmap semi-transparent based on intensity
|
|
imageData.data[index + 3] = value > 0 ? Math.round(value * 200 + 55) : 0; // Alpha (0-255)
|
|
}
|
|
}
|
|
|
|
// Scale the heatmap to a temporary canvas
|
|
const tempCanvas = canvas.constructor as any;
|
|
const tempCtx = new tempCanvas(finalConfig.width, finalConfig.height).getContext("2d");
|
|
tempCtx.putImageData(imageData, 0, 0);
|
|
|
|
// Clear main canvas
|
|
ctx.fillStyle = "white";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw background image if provided (use preloaded image if available)
|
|
if (finalConfig.preloadedBackground) {
|
|
ctx.drawImage(finalConfig.preloadedBackground, 0, 0, canvas.width, canvas.height);
|
|
} else if (finalConfig.backgroundImage) {
|
|
try {
|
|
const bgImage = await loadImage(finalConfig.backgroundImage);
|
|
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
|
|
} catch (error) {
|
|
console.error(`Failed to load background image: ${finalConfig.backgroundImage}`, error);
|
|
}
|
|
}
|
|
|
|
// Draw scaled heatmap on top with transparency
|
|
ctx.drawImage(tempCtx.canvas, 0, 0, canvas.width, canvas.height);
|
|
};
|
|
|
|
export const getReplayDuration = (replayData: ReplayData): number => {
|
|
let maxTime = 0;
|
|
|
|
for (const player of replayData.players) {
|
|
for (const snapshot of player.snapshots) {
|
|
maxTime = Math.max(maxTime, snapshot.time);
|
|
}
|
|
}
|
|
|
|
return Math.ceil(maxTime); // Round up to get total seconds
|
|
}; |