Skip to main content

Heatmap Generator — Build Interactive Heatmaps with JavaScript Canvas

Create interactive heatmaps by clicking or dragging on the canvas. Adjust the radius and intensity sliders to control how heat points spread and blend together, then export your heatmap as a PNG image.

Instructions: Click on the canvas to add heat points, or click and drag to paint continuously. Use the radius and intensity sliders to fine-tune the heatmap appearance. Click "Clear" to reset and "Export PNG" to download your heatmap.

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