4
\$\begingroup\$

Using the following code, I am able to neatly draw a color wheel, however, performance is barely acceptable on a high end machine. Now, I know some tricks I could employ to make it run far more smoothly, at the cost of a lot of flexibility, but I am especially interested in optimizing the code itself (thus keeping the flexibility). And of course in any generic feedback.

// Utility functions (ignore mostly)
function hsv2rgb(h, s, v) {  
    var c = v * s;  
    var h1 = h / 60;  
    var x = c * (1 - Math.abs((h1 % 2) - 1));  
    var m = v - c;  
    var rgb;  
    
    if (typeof h == 'undefined') rgb = [0, 0, 0];
    else if (h1 < 1) rgb = [c, x, 0];
    else if (h1 < 2) rgb = [x, c, 0];
    else if (h1 < 3) rgb = [0, c, x];
    else if (h1 < 4) rgb = [0, x, c];
    else if (h1 < 5) rgb = [x, 0, c];
    else if (h1 <= 6) rgb = [c, 0, x];
    
    return [255 * (rgb[0] + m), 255 * (rgb[1] + m), 255 * (rgb[2] + m)];
} 
function hsv2rgbString(h,s,v){
    var rgb = hsv2rgb(h,s,v);
    rgb = rgb.map(Math.round);
    return "rgb("+rgb[0]+","+rgb[1]+","+rgb[2]+")";
}

// Code
var canvas = document.getElementById("picker");
var context = canvas.getContext("2d");
var rangeEl = document.getElementById('range');
var x = canvas.width / 2;
var y = canvas.height / 2;
var borderWidth = 2;
var totalRadius = canvas.width / 2 - borderWidth;
var type = "hsv";

// To trigger anti aliasing, performance is poor even without it
context.moveTo(x, y);

var drawPicker = function(){
    context.clearRect (0, 0, 150, 150);
    context.lineWidth = 1;

    for(var i=0;i<totalRadius;i++){
        var radius = totalRadius-i;
        
        if(type == "hsl"){
            var saturation = Math.floor(radius/totalRadius*100)/100;
            var lightness = rangeEl.value;
        }else if(type == "hsv"){
            var saturation = Math.floor(radius/totalRadius*100)/100;
            var value = rangeEl.value;
        }

        for(var angle=0; angle<=360; angle+=1){
            var startAngle = (angle-1) * Math.PI/180;
            var endAngle = (angle+1) * Math.PI/180;
            context.beginPath();
            context.arc(x, y, radius, startAngle, endAngle, false);
            context.closePath();
            if(type == "hsl"){
                context.strokeStyle = 'hsl('+angle+', '+saturation*100+'%, '+lightness*100+'%)';
            }else if(type == "hsv"){
                context.strokeStyle = hsv2rgbString(angle,saturation,value);
            }
            context.stroke();
        }
    }
    
    // Draw the final black border
    context.strokeStyle = "rgb(0,0,0)";
    context.lineWidth = borderWidth;
    context.beginPath();
    var borderRadius = totalRadius+borderWidth/2;
    context.arc(x, y, borderRadius, 0, 2*Math.PI, false);
    context.closePath();
    context.stroke();
}

drawPicker();
rangeEl.addEventListener("change",drawPicker);
<canvas id="picker" width="150" height="150"></canvas><br/>
<input id="range" type="range" min="0" max="1" value="1" step="0.01">

I am asking for a code review, but if anybody knows what's causing the white lines (the Moiré pattern) appear at a low value (the slider) then I would be forever grateful. Or rather, I get why they appear, I just don't know how to solve it... beyond doubling the resolution and downscaling.

\$\endgroup\$
  • \$\begingroup\$ Your code goes over all 360 degrees one by one and it does this for the entire span of the radius, so in total 360 * r iterations of arc draws, which is going to be slow. Also drawing a line is usually much faster than drawing an arc. HTML5 canvas has linear gradiant with which you can draw the radii with pure colour at one end (circumference) and pure white at the other end (centre). This answer has a live snippet drawing a HSV wheel. It draws, at most, 360 lines. \$\endgroup\$ – legends2k Jul 22 '16 at 4:53
4
\$\begingroup\$

I like the idea of drawing it using arc segments, but it's probably not the most efficient. There's no doubt some extra loss in coverting to a CSS-style rgb(...) string, which then has to be parsed again. And, as you say, it causes the Moiré artefacts. Otherwise, the code looks pretty good.

While I won't guarantee it's always faster (and there are some browser support issues), you can try drawing the the color wheel pixel by pixel to an ImageData object, and pushing that to the canvas all at once. It seems faster, but I've also left out clipping the data to a wheel shape here.

You might also consider a square color field (rather than a wheel). That what this is of course, but the corners of it aren't too useful. Photoshop, for instance, defaults to a square field with saturation / lightness along the axes, and the slider determining the hue.

But Photoshop also has an RGB mode, where two of the components are used for the axes, and the last one is controlled by the slider. If you can construct a field that only requires you to update 1 byte per pixel, that'd probably be faster still.

var canvas = document.getElementById("picker");
var context = canvas.getContext("2d");
var rangeEl = document.getElementById('range');

function drawPicker() {
  // grab the current ImageData (or use createImageData)
  var bitmap = context.getImageData(0, 0, 150, 150);

  for (var y = 0; y < 150; y++) {
    for (var x = 0; x < 150; x++) {
      // offset for the 4 RGBA values in the data array
      var offset = 4 * ((y * 150) + x);

      var hue = 180 + Math.atan2(y - 75, x - 75) * (180 / Math.PI);
      var saturation = Math.sqrt(Math.pow(y - 75, 2) + Math.pow(x - 75, 2)) / 75;
      var value = rangeEl.value;

      saturation = Math.min(1, saturation);

      var hsv = hsv2rgb(hue, saturation, value);

      // fill RGBA values
      bitmap.data[offset + 0] = hsv[0];
      bitmap.data[offset + 1] = hsv[1];
      bitmap.data[offset + 2] = hsv[2];
      bitmap.data[offset + 3] = 255; // no transparency

    }
  }

  // update the canvas
  context.putImageData(bitmap, 0, 0);
}

// setup
drawPicker();
rangeEl.addEventListener("change", drawPicker);

// =========

function hsv2rgb(h, s, v) {
  var c = v * s;
  var h1 = h / 60;
  var x = c * (1 - Math.abs((h1 % 2) - 1));
  var m = v - c;
  var rgb;

  if (typeof h == 'undefined') rgb = [0, 0, 0];
  else if (h1 < 1) rgb = [c, x, 0];
  else if (h1 < 2) rgb = [x, c, 0];
  else if (h1 < 3) rgb = [0, c, x];
  else if (h1 < 4) rgb = [0, x, c];
  else if (h1 < 5) rgb = [x, 0, c];
  else if (h1 <= 6) rgb = [c, 0, x];

  var r = 255 * (rgb[0] + m);
  var g = 255 * (rgb[1] + m);
  var b = 255 * (rgb[2] + m);

  return [r, g, b];
}
<canvas id="picker" width="150" height="150"></canvas>
<br>
<input id="range" type="range" min="0" max="1" value="1" step="0.01">

Alternative to that: Cheat! Make the CSS background of the canvas black, and fade the color wheel in front of it using the slider. That's one way to get away with just changing 1 byte per pixel, and letting the browser handle compositing.

var canvas = document.getElementById("picker");
var context = canvas.getContext("2d");
var rangeEl = document.getElementById('range');
var colorWheel = null;

// update the alpha bytes of the color wheel
function updatePicker() {
  var bitmap = context.getImageData(0, 0, 150, 150);
  for (var y = 0; y < 150; y++) {
    for (var x = 0; x < 150; x++) {
      var offset = ((y * 150) + x) * 4 + 3;
      colorWheel.data[offset] = rangeEl.value | 0;
    }
  }

  // update the canvas
  context.putImageData(colorWheel, 0, 0);
}

// setup
drawPicker();
rangeEl.addEventListener("change", updatePicker);

// =========

function hsv2rgb(h, s, v) {
  var c = v * s;
  var h1 = h / 60;
  var x = c * (1 - Math.abs((h1 % 2) - 1));
  var m = v - c;
  var rgb;

  if (typeof h == 'undefined') rgb = [0, 0, 0];
  else if (h1 < 1) rgb = [c, x, 0];
  else if (h1 < 2) rgb = [x, c, 0];
  else if (h1 < 3) rgb = [0, c, x];
  else if (h1 < 4) rgb = [0, x, c];
  else if (h1 < 5) rgb = [x, 0, c];
  else if (h1 <= 6) rgb = [c, 0, x];

  var r = 255 * (rgb[0] + m);
  var g = 255 * (rgb[1] + m);
  var b = 255 * (rgb[2] + m);

  return [r, g, b];
}

// create the initial color wheel
function drawPicker() {
  colorWheel = context.getImageData(0, 0, 150, 150);

  for (var y = 0; y < 150; y++) {
    for (var x = 0; x < 150; x++) {
      var offset = 4 * ((y * 150) + x);

      var hue = 180 + Math.atan2(y - 75, x - 75) * (180 / Math.PI);
      var saturation = Math.sqrt(Math.pow(y - 75, 2) + Math.pow(x - 75, 2)) / 75;

      saturation = Math.min(1, saturation);

      var hsv = hsv2rgb(hue, saturation, 1); // fixed value

      colorWheel.data[offset + 0] = hsv[0];
      colorWheel.data[offset + 1] = hsv[1];
      colorWheel.data[offset + 2] = hsv[2];
      colorWheel.data[offset + 3] = 255;
    }
  }
  context.putImageData(colorWheel, 0, 0);
}
#picker {
  background: black
}
<canvas id="picker" width="150" height="150"></canvas>
<br>
<input id="range" type="range" min="0" max="255" value="255" step="1">

Or better yet: Cheat completely. Just put an image of a color wheel on top of a black box, and fade the image using the slider. No canvas, no pixel manipulation, none of that.

var wheel = document.getElementById("wheel"),
    slider = document.getElementById("slider");

slider.addEventListener("change", function () {
  wheel.style.opacity = this.value;
});
#picker {
  width: 150px;
  background: black;
}
<div id="picker">
  <img id="wheel" alt="" src="http://i.stack.imgur.com/TBVWx.png">
</div>
<input id="slider" type="range" value="1" min="0" max="1" step="0.01">

That's probably the simplest :)

Note: I haven't benchmarked any of the above. That's left as an exercise to the reader.

\$\endgroup\$
  • \$\begingroup\$ Yeah, I thought of the cheat - that was the trick I was referring to - but by doing that I have manually have two different tricks for hsv and hsl (second is easy yeah, first is harder). And regarding the third option, I wish to make a relatively flexible component, so trying to refrain from doing that :S . First looks very promising though, have to take a closer look at that one (which I might do tonight still (12PM here) or otherwise tomorrow) \$\endgroup\$ – David Mulder Nov 14 '14 at 22:59
  • \$\begingroup\$ @DavidMulder Ah, yes, you did mention the flexibility - forgot that while writing my answer. But happy to hear some of it might of use. \$\endgroup\$ – Flambino Nov 14 '14 at 23:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.