7

I am trying to draw a continous gradient along a path of points, where each point has a it's own color, using the HTML5 canvas API.

See http://bl.ocks.org/rveciana/10743959 for inspiration, where that effect is achieved with D3.

There doesn't seem to be a way to add multiple linear gradients for a single canvas path, so I resorted to something like this: http://jsfiddle.net/51toapv2/

var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');

var pts = [[100, 100, "red"], [150, 150, "green"], [200, 100, "yellow"]];

ctx.lineWidth = 20;
ctx.lineJoin = "round";
ctx.lineCap = "round";

for (var i = 0; i < pts.length - 1; i++) {
    var begin = pts[i];
    var end = pts[i + 1];

    ctx.beginPath();
    var grad = ctx.createLinearGradient(begin[0], begin[1], end[0], end[1]);
    grad.addColorStop(0, begin[2]);
    grad.addColorStop(1, end[2]);
    ctx.strokeStyle = grad;
    ctx.moveTo(begin[0], begin[1]);
    ctx.lineTo(end[0], end[1]);
    ctx.stroke();
}

As you can see it produces a subpar effect as the paths aren't merged and the "line joins" are clearly visible.

Is it possible to achieve the effect I'm looking for with the canvas API?

SiimKallas
  • 934
  • 11
  • 23
  • If you inspect the result of http://bl.ocks.org/rveciana/10743959 you see that the path is actually made up of small rectangles colored and rotated to create a path. I feel like you have to go the same road. – tiblu Jun 03 '15 at 22:15

2 Answers2

10

Here's a slight modification of your original idea that makes the joins blend nicely.

enter image description here

Original: Draw a gradient line from the start to end of a line segment.

This causes the line joins to overlap and produces a noticeable & undesired transition.

enter image description here

Modification: Draw a gradient line that doesn't extend to the start / endpoints.

With this modification, the line joins will always be solid colors rather than be partially gradiented. As a result, the line joins will transition nicely between line segments.

enter image description here

Here's example code and a Demo:

var canvas=document.getElementById("canvas");
var ctx=canvas.getContext("2d");

var lines = [
  {x:100, y:050,color:'red'},
  {x:150, y:100,color:'green'},
  {x:200, y:050,color:'gold'},
  {x:275, y:150,color:'blue'}
];
var linewidth=20;

ctx.lineCap='round';
ctx.lineJoint='round';

for(var i=1;i<lines.length;i++){

  // calculate the smaller part of the line segment over
  //     which the gradient will run
  var p0=lines[i-1];
  var p1=lines[i];
  var dx=p1.x-p0.x;
  var dy=p1.y-p0.y;
  var angle=Math.atan2(dy,dx);
  var p0x=p0.x+linewidth*Math.cos(angle);
  var p0y=p0.y+linewidth*Math.sin(angle);
  var p1x=p1.x+linewidth*Math.cos(angle+Math.PI);
  var p1y=p1.y+linewidth*Math.sin(angle+Math.PI);

  // determine where the gradient starts and ends
  if(i==1){
    var g=ctx.createLinearGradient(p0.x,p0.y,p1x,p1y);   
  }else if(i==lines.length-1){
    var g=ctx.createLinearGradient(p0x,p0y,p1.x,p1.y);
  }else{
    var g=ctx.createLinearGradient(p0x,p0y,p1x,p1y);
  }

  // add the gradient color stops
  // and draw the gradient line from p0 to p1
  g.addColorStop(0,p0.color);
  g.addColorStop(1,p1.color);
  ctx.beginPath();
  ctx.moveTo(p0.x,p0.y);
  ctx.lineTo(p1.x,p1.y);
  ctx.strokeStyle=g;
  ctx.lineWidth=linewidth;
  ctx.stroke();
}
#canvas{border:1px solid red; margin:0 auto; }
<canvas id="canvas" width=350 height=200></canvas>
markE
  • 102,905
  • 11
  • 164
  • 176
  • 1
    Just curious: how would you solve curvy/"arc" lines wo/ recalculating/splitting the gradient? –  Jun 04 '15 at 17:18
  • @K3N Arcs & Bezier curves are a bit more complicated--And yes, they require splitting (interpolating) colors of the desired gradient. They require breaking up the curves into the points that plot the curves. Then use those plot-points to draw a gradient that progresses along with the points from start to end. The gradient must be interpolated to coincide with each plot-point. F.ex: If a curve consists of 200 plot-points then you would chop (interpolate) the gradient color into 200 incremental colors--one color for each point. -- continued... – markE Jun 04 '15 at 17:41
  • ...continued: Here's a previous post in which I (ironically) show how to draw a gradient across the line width of a Bezier curve. But you can adapt the same technique to draw incremental gradient colors-one color for each point on the Bezier. http://stackoverflow.com/questions/24027087/gradient-stroke-along-curve-in-canvas/24029653#24029653 Cheers! :-) – markE Jun 04 '15 at 17:44
  • @K3N No and yes! It depends on the dataset. OP's fiddle has data points far enough apart that I would use my technique--especially since my technique correctly paints the desired gradient (red/green/yellow) between the data points with no expensive interpolation. My technique defines data in the same format as the OP ({x:,y:,color:}) so it's more applicable given sequential points with the same color like hurricane intensity map data (red, red, red, green, green, yellow). BTW, I do like your answer!! It's on-target & deserving! I would upvote it if you change it to work with OP's dataset. :-) – markE Jun 04 '15 at 19:19
  • "BTW, I do like your answer!! It's on-target & deserving! I would upvote it if you change it to work with OP's dataset. :-) " Thanks (?) –  Jun 04 '15 at 19:45
9

You can do a simple approach interpolating two colors along a line. If you need smooth/shared gradients where two lines joins at steeper angles, you would need to calculate and basically implement a line drawing algorithm from (almost) scratch. This would be out of scope for SO, so here is a simpler approach.

That being said - the example in the link is not actually a line but several plots of squares of different colors. The issues it would have too is "hidden" by its subtle variations.

Example

snapshot

This approach requires two main functions:

  1. Line interpolate function which draws each segment in a line from previous mouse position to current position

  2. Color interpolate function which takes an array of colors and interpolate between two current colors depending on length, position and segment size.

Tweak parameters such as segment size, number of colors in the array etc. to get the optimal result.

Line interpolate function

function plotLine(ctx, x1, y1, x2, y2) {

  var diffX = Math.abs(x2 - x1),      // get line length
      diffY = Math.abs(y2 - y1),
      dist = Math.sqrt(diffX * diffX + diffY * diffY),
      step = dist / 10,               // define some resolution
      i = 0, t, b, x, y;

  while (i <= dist) {                 // render circles along the line
    t = Math.min(1, i / dist);

    x = x1 + (x2 - x1) * t;
    y = y1 + (y2 - y1) * t;

    ctx.fillStyle = getColor();       // get current color
    ctx.beginPath();
    ctx.arc(x, y, 10, 0, Math.PI*2);
    ctx.fill();
    i += step;
  }

Color interpolate function

  function getColor() {

    var r, g, b, t, c1, c2;

    c1 = colors[cIndex];                           // get current color from array
    c2 = colors[(cIndex + 1) % maxColors];         // get next color
    t = Math.min(1, total / segment);              // calculate t

    if (++total > segment) {                       // rotate segment
      total = 0;
      if (++cIndex >= maxColors) cIndex = 0;       // rotate color array
    }

    r = c1.r + (c2.r - c1.r) * t;                  // interpolate color
    g = c1.g + (c2.g - c1.g) * t;
    b = c1.b + (c2.b - c1.b) * t;

    return "rgb(" + (r|0) + "," + (g|0) + "," + (b|0) + ")";
  }

Demo

Putting it all together will allow you to draw gradient lines. If you don't want to draw them manually simply call the plotLine() function whenever needed.

// Some setup code
var c = document.querySelector("canvas"),
    ctx = c.getContext("2d"),
    colors = [
      {r: 255, g: 0, b: 0},
      {r: 255, g: 255, b: 0},
      {r: 0, g: 255, b: 0},
      {r: 0, g: 255, b: 255},
      {r: 0, g: 0, b: 255},
      {r: 255, g: 0, b: 255},
      {r: 0, g: 255, b: 255},
      {r: 0, g: 255, b: 0},
      {r: 255, g: 255, b: 0},
    ],
    cIndex = 0, maxColors = colors.length,
    total = 0, segment = 500,
    isDown = false, px, py;

setSize();
      
c.onmousedown = c.ontouchstart = function(e) {
  isDown = true;
  var pos = getPos(e);
  px = pos.x;
  py = pos.y;
};

window.onmousemove = window.ontouchmove = function(e) {if (isDown) plot(e)};
window.onmouseup = window.ontouchend = function(e) {
  e.preventDefault();
  isDown = false
};

function getPos(e) {
  e.preventDefault();
  if (e.touches) e = e.touches[0];
  var r = c.getBoundingClientRect();
  return {
    x: e.clientX - r.left,
    y: e.clientY - r.top
  }
}

function plot(e) {
  var pos = getPos(e);
  plotLine(ctx, px, py, pos.x, pos.y);
  px = pos.x;
  py = pos.y;
}

function plotLine(ctx, x1, y1, x2, y2) {

  var diffX = Math.abs(x2 - x1),
      diffY = Math.abs(y2 - y1),
      dist = Math.sqrt(diffX * diffX + diffY * diffY),
      step = dist / 50,
      i = 0,
      t, b, x, y;
  
  while (i <= dist) {
    t = Math.min(1, i / dist);

    x = x1 + (x2 - x1) * t;
    y = y1 + (y2 - y1) * t;

    ctx.fillStyle = getColor();
    ctx.beginPath();
    ctx.arc(x, y, 10, 0, Math.PI*2);
    ctx.fill();
    i += step;
  }
  
  function getColor() {
  
    var r, g, b, t, c1, c2;
    
    c1 = colors[cIndex];
    c2 = colors[(cIndex + 1) % maxColors];
    t = Math.min(1, total / segment);
    
    if (++total > segment) {
      total = 0;
      if (++cIndex >= maxColors) cIndex = 0;
    }
  
    r = c1.r + (c2.r - c1.r) * t;
    g = c1.g + (c2.g - c1.g) * t;
    b = c1.b + (c2.b - c1.b) * t;
  
    return "rgb(" + (r|0) + "," + (g|0) + "," + (b|0) + ")";
  }
}

window.onresize = setSize;
function setSize() {
  c.width = window.innerWidth;
  c.height = window.innerHeight;
}
document.querySelector("button").onclick = function() {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
};
html, body {background:#777; margin:0; overflow:hidden}
canvas {position:fixed;left:0;top:0;background: #333}
button {position:fixed;left:10px;top:10px}
<canvas></canvas>
<button>Clear</button>

TIPS:

  • The gradient values can be pre-populated / cached beforehand
  • The step for position in gradient can be bound to length to get even spread independent of draw speed
  • You can easily replace the brush with other path/figures/shapes, even combine image based brushes which is composited with current color
  • 2
    @markE "Nice. You might want to adjust segment=500 when plotLine is called under program control for just a few points. For example: plotLine(ctx,100,100,150,150); and plotLine(ctx,150,150,200,100); will draw an all red line due to the few points involved. ;-) " Well, since 98-99% of the code is made I'll leave that as an exercise for OP ;-P –  Jun 04 '15 at 05:41
  • 1
    @markE I would go for length mapping but I skipped it to simplify the code a tad –  Jun 04 '15 at 05:42
  • That's a very nice method to do this. Thanks :) – SiimKallas Jun 04 '15 at 15:11
  • @Ynau no problem! :) I forgot to mention this approach also allow you to use custom brushes by replacing the arc() with a shape etc. –  Jun 04 '15 at 17:15
  • this example snippet crashes my chrome browser. also tried implementing it on my own code and the same issue happens – wsgg Jan 06 '19 at 06:09
  • to simplify the coloring code _significantly_ don't use RGB colours, use HSL colors instead, incrementing the hue value at every call. Rather than a lengthy getColor, all we then need is: ```let h=0; function getColor() { return `hsl(${h++}, 100%, 50%)`; }```. And then we also don't need the coloring array and index anymore. – Mike 'Pomax' Kamermans Jun 23 '19 at 17:26