Tomáš Zato
Tomáš Zato

Reputation: 53129

Create color gradient describing discrete distribution of points

I am making a map that renders position of game objects (Project Zomboid zombies):

zombies

As user zooms out, single dots are no longer useful. Instead, I'd like to render distribution of zombies on an area using red color gradient. I tried to loop over all zombies for every rendered pixel and color it reciprocally to the sum of squared distances to the zombies. The result:

image description

That's way too blurry. Also the results are more influenced by the zombies that are AWAY from the points - I need to influence them more by the zombies that are CLOSE. So what this is is just math. Here's the code I used:

var h = canvas.height;
var w = canvas.width; 
// To loop over more than 1 pixel (performance)
var tileSize = 10;
var halfRadius = Math.floor(tileSize/2);
var time = performance.now();
// "Squared" because we didnt unsquare it   
function distanceSquared(A, B) {
  return (A.x-B.x)*(A.x-B.x)+(A.y-B.y)*(A.y-B.y);
}
// Loop for every x,y pixel (or region of pixels)
for(var y=0; y<h; y+=tileSize) {
  for(var x=0; x<w; x+=tileSize) {
     // Time security - stop rendering after 1 second
     if(performance.now()-time>1000) {
        x=w;y=h;break;
     }
     // Convert relative canvas offset to absolute point on the map
     var point = canvasPixeltoImagePixel(x, y);
     // For every zombie add sqrt(distance from this point to zombie)
     var distancesRoot = 0;
     // Loop over the zombies
     var zombieCoords; 
     for(var i=0; i<zombies_length; i++) {
       // Get single zombie coordinates as {x:0, y:0}
       if((coords=zombies[i].pixel)==null)
         coords = zombies[i].pixel = tileToPixel(zombies[i].coordinates[0], zombies[i].coordinates[1], drawer);
       // square root is a) slow and b) probably not what I want anyway
       var dist = distanceSquared(coords, point);
       distancesRoot+=dist; 
     }
     // The higher the sum of distances is, the more intensive should the color be
     var style = 'rgba(255,0,0,'+300000000/distancesRoot+')';
     // Kill the console immediatelly
     //console.log(style);
     // Maybe we should sample and cache the transparency styles since there's limited ammount of colors?
     ctx.fillStyle = style;
     ctx.fillRect(x-halfRadius,y-halfRadius,tileSize,tileSize);
  } 
}

I'm pretty fine with theoretical explanation how to do it, though if you make simple canvas example with some points, what would be awesome.

Upvotes: 0

Views: 1499

Answers (1)

wolfhammer
wolfhammer

Reputation: 2661

This is an example of a heat map. It's basically gradient orbs over points and then ramping the opacity through a heat ramp. The more orbs cluster together the more solid the color which can be shown as an amplified region with the proper ramp.

update

I cleaned up the variables a bit and put the zeeks in an animation loop. There's an fps counter to see how it's performing. The gradient circles can be expensive. We could probably do bigger worlds if we downscale the heat map. It won't be as smooth looking but will compute a lot faster.

update 2

The heat map now has an adjustable scale and as predicted we get an increase in fps.

if (typeof app === "undefined") {
  var app = {};
}

app.zeeks = 200;
app.w = 600;
app.h = 400;
app.circleSize = 50;
app.scale = 0.25;

init();

function init() {
  app.can = document.getElementById('can');
  app.ctx = can.getContext('2d');
  app.can.height = app.h;
  app.can.width = app.w;
  app.radius = Math.floor(app.circleSize / 2);
  
  app.z = genZ(app.zeeks, app.w, app.h);
  app.flip = false;

  // Make temporary layer once.
  app.layer = document.createElement('canvas');
  app.layerCtx = app.layer.getContext('2d');
  app.layer.width = Math.floor(app.w * app.scale);
  app.layer.height = Math.floor(app.h * app.scale);

  // Make the gradient canvas once.
  var sCircle = Math.floor(app.circleSize * app.scale);
  app.radius = Math.floor(sCircle / 2);
  app.gCan = genGradientCircle(sCircle);
  app.ramp = genRamp();
  
  // fps counter
  app.frames = 0;
  app.fps = "- fps";
  app.fpsInterval = setInterval(calcFps, 1000);
  
  // start animation
  ani();
  flicker();
}

function calcFps() {
  app.fps = app.frames + " fps";
  app.frames = 0;
}

// animation loop
function ani() {
  app.frames++;
  var ctx = app.ctx;
  var w = app.w;
  var h = app.h;
  moveZ();
  //ctx.clearRect(0, 0, w, h);
  ctx.fillStyle = "#006600";
  ctx.fillRect(0, 0, w, h);
  if (app.flip) {
    drawZ2();
    drawZ();
  } else {
    drawZ2();
  }
  ctx.fillStyle = "#FFFF00";
  ctx.fillText(app.fps, 10, 10);
  requestAnimationFrame(ani);
}

function flicker() {
  
  app.flip = !app.flip;
  if (app.flip) {
    setTimeout(flicker, 500);
  } else {
    setTimeout(flicker, 5000);
  }
  
}

function genGradientCircle(size) {
  // gradient image
  var gCan = document.createElement('canvas');
  gCan.width = gCan.height = size;
  var gCtx = gCan.getContext('2d');
  var radius = Math.floor(size / 2);
  var grad = gCtx.createRadialGradient(radius, radius, radius, radius, radius, 0);
  grad.addColorStop(1, "rgba(255,255,255,.65)");
  grad.addColorStop(0, "rgba(255,255,255,0)");
  gCtx.fillStyle = grad;
  gCtx.fillRect(0, 0, gCan.width, gCan.height);
  return gCan;
}

function genRamp() {
  // Create heat gradient
  var heat = document.createElement('canvas');
  var hCtx = heat.getContext('2d');
  heat.width = 256;
  heat.height = 5;
  var linGrad = hCtx.createLinearGradient(0, 0, heat.width, heat.height);
  linGrad.addColorStop(1, "rgba(255,0,0,.75)");
  linGrad.addColorStop(0.5, "rgba(255,255,0,.03)");
  linGrad.addColorStop(0, "rgba(255,255,0,0)");
  hCtx.fillStyle = linGrad;
  hCtx.fillRect(0, 0, heat.width, heat.height);

  // create ramp from gradient
  var ramp = [];
  var imageData = hCtx.getImageData(0, 0, heat.width, 1);
  var d = imageData.data;
  for (var x = 0; x < heat.width; x++) {
    var i = x * 4;
    ramp[x] = [d[i], d[i + 1], d[i + 2], d[i + 3]];
  }

  return ramp;
}

function genZ(n, w, h) {
  var a = [];
  for (var i = 0; i < n; i++) {
    a[i] = [
      Math.floor(Math.random() * w),
      Math.floor(Math.random() * h),
      Math.floor(Math.random() * 3) - 1,
      Math.floor(Math.random() * 3) - 1
    ];
  }
  return a;
}

function moveZ() {
  var w = app.w
  var h = app.h;
  var z = app.z;
  for (var i = 0; i < z.length; i++) {
    var s = z[i];
    s[0] += s[2];
    s[1] += s[3];
    if (s[0] > w || s[0] < 0) s[2] *= -1;
    if (s[1] > w || s[1] < 0) s[3] *= -1;
  }
}

function drawZ() {
  var ctx = app.ctx;
  var z = app.z;
  ctx.fillStyle = "#FFFF00";
  for (var i = 0; i < z.length; i++) {
    ctx.fillRect(z[i][0] - 2, z[i][1] - 2, 4, 4);
  }
}

function drawZ2() {
  var ctx = app.ctx;
  var layer = app.layer;
  var layerCtx = app.layerCtx;
  var gCan = app.gCan;
  var z = app.z;
  var radius = app.radius;
  
  // render gradients at coords onto layer
  for (var i = 0; i < z.length; i++) {
    var x = Math.floor((z[i][0] * app.scale) - radius);
    var y = Math.floor((z[i][1] * app.scale) - radius);
    layerCtx.drawImage(gCan, x, y);
  }

  // adjust layer for heat ramp
  var ramp = app.ramp;

  // apply ramp to layer
  var imageData = layerCtx.getImageData(0, 0, layer.width, layer.height);

  d = imageData.data;
  for (var i = 0; i < d.length; i += 4) {
    if (d[i + 3] != 0) {
      var c = ramp[d[i + 3]];
      d[i] = c[0];
      d[i + 1] = c[1];
      d[i + 2] = c[2];
      d[i + 3] = c[3];
    }
  }

  layerCtx.putImageData(imageData, 0, 0);

  // draw layer on world
  ctx.drawImage(layer, 0, 0, layer.width, layer.height, 0, 0, app.w, app.h);
}
<canvas id="can" width="600" height="400"></canvas>

Upvotes: 2

Related Questions