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);
});