Skip to main content

Heatmap Generator — 使用 JavaScript Canvas 构建交互式热力图

通过在画布上点击或拖动来创建交互式热力图。调整半径和强度滑块,控制热点如何扩散并相互融合,然后将热力图导出为 PNG 图像。

说明: 在画布上单击以添加热点,或单击并拖动以连续绘制。使用半径和强度滑块微调热力图外观。点击“清除”可重置,点击“导出 PNG”可下载你的热力图。

import Konva from 'konva';

// --- 控件 ---
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;gap:10px;align-items:center;margin-bottom:4px;flex-wrap:wrap;font-size:13px;';

const radiusLabel = document.createElement('label');
radiusLabel.textContent = '半径:';
const radiusSlider = document.createElement('input');
radiusSlider.type = 'range';
radiusSlider.min = '10';
radiusSlider.max = '80';
radiusSlider.value = '40';
radiusSlider.style.width = '80px';
const radiusVal = document.createElement('span');
radiusVal.textContent = '40px';
radiusLabel.appendChild(radiusSlider);
radiusLabel.appendChild(radiusVal);

const intensityLabel = document.createElement('label');
intensityLabel.textContent = '强度:';
const intensitySlider = document.createElement('input');
intensitySlider.type = 'range';
intensitySlider.min = '1';
intensitySlider.max = '10';
intensitySlider.value = '5';
intensitySlider.style.width = '80px';
const intensityVal = document.createElement('span');
intensityVal.textContent = '0.5';
intensityLabel.appendChild(intensitySlider);
intensityLabel.appendChild(intensityVal);

const clearBtn = document.createElement('button');
clearBtn.textContent = '清除';
const exportBtn = document.createElement('button');
exportBtn.textContent = '导出 PNG';

controls.appendChild(radiusLabel);
controls.appendChild(intensityLabel);
controls.appendChild(clearBtn);
controls.appendChild(exportBtn);
const container = document.getElementById('container');
container.parentNode.insertBefore(controls, container);

radiusSlider.addEventListener('input', () => { radiusVal.textContent = radiusSlider.value + 'px'; });
intensitySlider.addEventListener('input', () => { intensityVal.textContent = (intensitySlider.value / 10).toFixed(1); });

// --- 舞台 ---
const width = window.innerWidth;
const height = window.innerHeight - 40;

const stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});

const layer = new Konva.Layer();
stage.add(layer);

// 深色背景
const bg = new Konva.Rect({ x: 0, y: 0, width, height, fill: '#1a1a2e' });
layer.add(bg);

// 用于热力图渲染的离屏画布
const shadowCanvas = document.createElement('canvas');
shadowCanvas.width = width;
shadowCanvas.height = height;
const shadowCtx = shadowCanvas.getContext('2d');

const heatPoints = [];
let heatImage = null;
let isDrawing = false;

function drawHeatPoint(ctx, x, y, radius, intensity) {
// 使用带颜色渐变的叠加混合——无需像素循环
ctx.globalCompositeOperation = 'lighter';
const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
// 中心:暖红/橙色,边缘:冷蓝色,逐渐透明
grad.addColorStop(0, 'rgba(255, 80, 0, ' + intensity + ')');
grad.addColorStop(0.3, 'rgba(255, 200, 0, ' + (intensity * 0.7) + ')');
grad.addColorStop(0.6, 'rgba(0, 200, 100, ' + (intensity * 0.3) + ')');
grad.addColorStop(0.85, 'rgba(0, 100, 255, ' + (intensity * 0.15) + ')');
grad.addColorStop(1, 'rgba(0, 0, 100, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}

function renderHeatmap() {
shadowCtx.globalCompositeOperation = 'source-over';
shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height);

heatPoints.forEach(function(p) {
drawHeatPoint(shadowCtx, p.x, p.y, p.r, p.i);
});

if (heatImage) {
heatImage.destroy();
}
heatImage = new Konva.Image({
image: shadowCanvas,
x: 0,
y: 0,
listening: false,
});
layer.add(heatImage);
bg.moveToBottom();
}

function addPoint(pos) {
heatPoints.push({
x: pos.x,
y: pos.y,
r: parseInt(radiusSlider.value),
i: parseInt(intensitySlider.value) / 10,
});
renderHeatmap();
}

stage.on('mousedown touchstart', function(e) {
isDrawing = true;
addPoint(stage.getPointerPosition());
});

stage.on('mousemove touchmove', function() {
if (!isDrawing) return;
addPoint(stage.getPointerPosition());
});

stage.on('mouseup touchend mouseleave', function() {
isDrawing = false;
});

clearBtn.addEventListener('click', function() {
heatPoints.length = 0;
renderHeatmap();
});

exportBtn.addEventListener('click', function() {
const dataURL = stage.toDataURL({ pixelRatio: 2 });
const link = document.createElement('a');
link.download = 'heatmap.png';
link.href = dataURL;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});