跳到主要内容

热图生成器——使用 JavaScript Canvas 构建交互式热图

通过在 Canvas 上单击或拖动来创建交互式热图。调整半径和强度滑块,以控制热点的扩散和混合方式。然后将热图导出为 PNG 图像。

**操作说明:**单击 Canvas 以添加热点,或通过单击并拖动来连续绘制。使用半径和强度滑块微调热图外观。单击“Clear”以重置,单击“Export PNG”以下载热图。

import Konva from 'konva';

// --- Controls ---
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 = 'Radius: ';
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 = 'Intensity: ';
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 = 'Clear';
const exportBtn = document.createElement('button');
exportBtn.textContent = 'Export 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); });

// --- Stage ---
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);

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

// Offscreen canvas for heatmap rendering
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) {
  // Use additive blending with colored gradients — no pixel loop needed
  ctx.globalCompositeOperation = 'lighter';
  const grad = ctx.createRadialGradient(x, y, 0, x, y, radius);
  // Center: warm red/orange, edges: cool blue, fading to transparent
  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);
});