user3896501
user3896501

Reputation: 3037

HTML5 Canvas How to draw squircle with gradient border?

After I googling a lot, I cannot find any tutorials which answering how to draw squircle shape in HTML5 canvas, please forgive me as I am very poor on math.

However I do find some similar / related answers, but I don't know how to combine these knowledges...

HTML5 Canvas alpha transparency doesn't work in firefox for curves when window is big

Continuous gradient along a HTML5 canvas path

https://stackoverflow.com/a/44856925/3896501

The effect I try to achieve: enter image description here

Thanks for any help!

UPDATE 1:

Code so far I created:

<body>
  <div class="con">
    <div class="ava"></div>
    <canvas id="canvas"></canvas>
  </div>
  <script>

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

    var shadowPadding = 8;
    var strokeWidth = 2;
    canvas.width = canvas.height = (64 + shadowPadding * 2) * window.devicePixelRatio
    canvas.style.width = canvas.style.height = `${canvas.width / window.devicePixelRatio}px`

    function drawMultiRadiantCircle(xc, yc, r, radientColors) {
        var partLength = (2 * Math.PI) / radientColors.length;
        var start = 0;
        var gradient = null;
        var startColor = null,
            endColor = null;

        for (var i = 0; i < radientColors.length; i++) {
            startColor = radientColors[i];
            endColor = radientColors[(i + 1) % radientColors.length];

            // x start / end of the next arc to draw
            var xStart = xc + Math.cos(start) * r;
            var xEnd = xc + Math.cos(start + partLength) * r;
            // y start / end of the next arc to draw
            var yStart = yc + Math.sin(start) * r;
            var yEnd = yc + Math.sin(start + partLength) * r;

            ctx.beginPath();

            gradient = ctx.createLinearGradient(xStart, yStart, xEnd, yEnd);
            gradient.addColorStop(0, startColor);
            gradient.addColorStop(1, endColor);

            ctx.lineWidth = strokeWidth;
            ctx.strokeStyle = gradient;

            // squircle START
            // https://stackoverflow.com/questions/50206406/drawing-a-squircle-shape-on-canvas-android
            // //Formula: (|x|)^3 + (|y|)^3 = radius^3
            // ctx.moveTo(-r, 0);
            // const radiusToPow = r ** 3;
            // const rad = r
            // for (let x = -rad ; x <= rad ; x++)
            //   ctx.lineTo(x + r, Math.cbrt(radiusToPow - Math.abs(x ** 3)) + r);
            // for (let x = rad ; x >= -rad ; x--)
            //   ctx.lineTo(x + r, -Math.cbrt(radiusToPow - Math.abs(x ** 3)) + r);
            // ctx.translate(r, r)
            // ctx.restore()
            // squircle END

            // circle START
            // https://stackoverflow.com/a/22231473/3896501
            ctx.arc(xc, yc, r, start, start + partLength);
            // circle END
            if (i === 1) {
              break
            }
            ctx.stroke();
            ctx.closePath();

            start += partLength;
        }
    }

    var someColors = [];
    someColors.push('#0F0');
    someColors.push('#0FF');
    someColors.push('#F00');
    someColors.push('#FF0');
    someColors.push('#F0F');

    var mid = canvas.width / 2;
    var r = (canvas.width - (shadowPadding * 2)) / 2 + (strokeWidth / 2)
    drawMultiRadiantCircle(mid, mid, r, someColors);

  </script>
  <style>
  .con {
    align-items: center;
    justify-content: center;
    display: flex;
    height: 4rem;
    margin: 6rem;
    width: 4rem;
    position: relative;
  }
  .ava {
    background: #555 50% no-repeat;
    background-size: contain;
    border-radius: 24px;
    height: 100%;
    width: 100%;
  }
  canvas {
    height: 100%;
    width: 100%;
    position: absolute;
  }
  </style>
</body>

drawing portion of circle with gradient color:

drawing a squircle:

I don't know how to code a algorithm that draws a portion of squircle just like what context.arc does.

Upvotes: 4

Views: 2330

Answers (2)

If we read the wikipedia article on squircles, we see that this is just the unweighted ellipse function using powers of 2 or higher, which means we can pretty easily compute the "y" values given "x" values and draw things that way, but doing so will give us extremely uneven segments: small changes in x will lead to HUGE changes in y at the start and end points, and tiny changes in y at the midpoint.

Instead, let's model the squircle as a parametric function, so we vary one control value and get reasonably evenly spaced intervals to work with. We can find this explained in the wikipedia article on the superellipse function:

x = |cos(t)^(2/n)| * sign(cos(t))
y = |sin(t)^(2/n)| * sign(sin(t))

for t from 0 to 2π, and the radii fixed to 1 (so they disappear from multiplications).

If we implement that, then we can add the rainbow coloring almost as an afterthought, drawing each path segment separately, with a strokeStyle coloring that uses HSL colors where the hue values shifts based on our t value:

// alias some math functions so we don't need that "Math." all the time
const abs=Math.abs, sign=Math.sign, sin=Math.sin, cos=Math.cos, pow=Math.pow;

// N=2 YIELDS A CIRCLE, N>2 YIELDS A SQUIRCLE
const n = 4;

function coord(t) {
  let power = 2/n;
  let c = cos(t), x = pow(abs(c), power) * sign(c);
  let s = sin(t), y = pow(abs(s), power) * sign(s);
  return { x, y };
}

function drawSegmentTo(t) {
  let c = coord(t);
  let cx = dim + r * c.x;     // Here, dim is our canvas "radius",
  let cy = dim + r * c.y;     // and r is our circle radius, with
  ctx.lineTo(cx, cy);         // ctx being our canvas context.

  // stroke segment in rainbow colours
  let h = (360 * t)/TAU;
  ctx.strokeStyle = `hsl(${h}, 100%, 50%)`;
  ctx.stroke();  

  // start a new segment at the end point
  ctx.beginPath();
  ctx.moveTo(cx, cy);
}

We can then use this in combination with some standard Canvas2D API code:

const PI = Math.PI,
      TAU = PI * 2,
      edge = 200, // SIZE OF THE CANVAS, IN PIXELS
      dim = edge/2,
      r = dim * 0.9,
      cvs = document.getElementById('draw');

// set up our canvas
cvs.height = cvs.width = edge;
ctx = cvs.getContext('2d');
ctx.lineWidth = 2;
ctx.fillStyle = '#004';
ctx.strokeStyle = 'black';
ctx.fillRect(0, 0, edge, edge);

And with all that setup complete, the draw code is really straight-forward:

// THIS DETERMINES HOW SMOOTH OF A CURVE GETS DRAWN
const segments = 32;

// Peg our starting point, which we know is (r,0) away from the center.
ctx.beginPath();
ctx.moveTo(dim + r, dim)

// Then we generate all the line segments on the path
for (let step=TAU/segments, t=step; t<=TAU; t+=step) drawSegmentTo(t);

// And because IEEE floats are imprecise, the last segment may not
// actually reach our starting point. As such, make sure to draw it!
ctx.lineTo(dim + r, dim);
ctx.stroke();

Running this will yield the following squircle:

a rainbox squircle of power 4

With a jsbin so you can play with the numbers: https://jsbin.com/haxeqamilo/edit?js,output

Of course, you can also go a completely other way: Create an SVG element (since SVG is part of HTML5) with a <path> element and appropriately set width, height, and viewbox, and then generate a d attribute and gradient-color that, but that's definitely way more finnicky.

Upvotes: 7

tevemadar
tevemadar

Reputation: 13225

With a mathematical expression at hand you can do a full scan of the bounding rectangle, and evaluate pixel-by-pixel if

  • it lies outside
  • it is part of the border
  • it lies inside

For the gradient thing I would apply some continuous function(s) to the angle. Like some sin/cos thing:

let ctx=cnv.getContext("2d");

function gradient(angle){
  return "rgb("+
    (128+127*Math.sin(angle*8))+","+
    (128+127*Math.cos(angle*6))+","+
    (128+127*Math.sin(angle*16))+")";
}

for(let x=0;x<360;x++){
  ctx.fillStyle=gradient(x*Math.PI/180);
  ctx.fillRect(250-180+x,0,1,10);
}

let mx=250,my=90,rx=70,ry=70;

let start=Date.now();

for(let x=-rx;x<=rx;x++)
  for(let y=-ry;y<=ry;y++){
    let r4=Math.pow(x/rx,4)+Math.pow(y/ry,4);
    if(r4<0.8){
      ctx.fillStyle="gray";
      ctx.fillRect(mx+x,my+y,1,1);
    }else if(r4<1){
      ctx.fillStyle=gradient(Math.atan2(x,y));
      ctx.fillRect(mx+x,my+y,1,1);
    }
  }

console.log(Date.now()-start);
<canvas id="cnv" width="500" height="170"></canvas>

For real-life use this approach may perform better with off-screen composition into ImageData and perhaps pre-calculating the gradient too:

let ctx=cnv.getContext("2d");

let gradient=new Uint8Array(360*3);

for(let x=0;x<360;x++){
  let r=gradient[x*3]=128+127*Math.sin(x*Math.PI/180*8);
  let g=gradient[x*3+1]=128+127*Math.cos(x*Math.PI/180*6);
  let b=gradient[x*3+2]=128+127*Math.sin(x*Math.PI/180*16);
  ctx.fillStyle="rgb("+r+","+g+","+b+")";
  ctx.fillRect(250-180+x,0,1,10);
}

let mx=250,my=90,rx=70,ry=70;

let start=Date.now();

let imgdata=ctx.createImageData(rx*2+1,ry*2+1);
let data=imgdata.data;

for(let y=-ry,idx=0;y<=ry;y++)
  for(let x=-rx;x<=rx;x++){
    let r4=Math.pow(x/rx,4)+Math.pow(y/ry,4);
    if(r4<0.8){
      data[idx++]=128;
      data[idx++]=128;
      data[idx++]=128;
      data[idx++]=255;
    }else if(r4<1){
      gidx=Math.floor(180+Math.atan2(x,y)*180/Math.PI)%360*3;
      data[idx++]=gradient[gidx++];
      data[idx++]=gradient[gidx++];
      data[idx++]=gradient[gidx++];
      data[idx++]=255;
    }else idx+=4;
  }

ctx.putImageData(imgdata,mx-rx,my-ry);

console.log(Date.now()-start);
<canvas id="cnv" width="500" height="170"></canvas>

On my machine this latter variant is slower for the first run (some 40 ms vs 35 ms), but gets significantly faster for subsequent ones (14 ms vs 31 ms, so the other one does not really speed up). But I have not checked if it is a result of ImageData, gradient[], or both.


EDIT 06-07-2019 applying suggestions, though not together...

Uint32Array makes it shorter, simpler and faster:

let ctx=cnv.getContext("2d");

let gradient=new Uint32Array(360);

for(let x=0;x<360;x++){
  let r=128+127*Math.sin(x*Math.PI/180*8);
  let g=128+127*Math.cos(x*Math.PI/180*6);
  let b=128+127*Math.sin(x*Math.PI/180*16);
  gradient[x]=0xFF000000+(b<<16)+(g<<8)+r;
  ctx.fillStyle="rgb("+r+","+g+","+b+")";
  ctx.fillRect(250-180+x,0,1,10);
}

let mx=250,my=90,rx=70,ry=70;

let start=Date.now();

let imgdata=ctx.createImageData(rx*2+1,ry*2+1);
let data=new Uint32Array(imgdata.data.buffer);

for(let y=-ry,idx=0;y<=ry;y++)
  for(let x=-rx;x<=rx;x++,idx++){
    let r4=Math.pow(x/rx,4)+Math.pow(y/ry,4);
    if(r4<0.8){
      data[idx]=0xFF808080;
    }else if(r4<1){
      gidx=Math.floor(180+Math.atan2(x,y)*180/Math.PI)%360;
      data[idx]=gradient[gidx];
    }
  }

ctx.putImageData(imgdata,mx-rx,my-ry);

console.log(Date.now()-start);
<canvas id="cnv" width="500" height="170"></canvas>

However anti-aliasing is not very trivial with 32-bit numbers, so this one reverts to separate components:

let ctx=cnv.getContext("2d");

let gradient=new Uint8Array(360*3);

for(let x=0;x<360;x++){
  let r=gradient[x*3]=128+127*Math.sin(x*Math.PI/180*8);
  let g=gradient[x*3+1]=128+127*Math.cos(x*Math.PI/180*6);
  let b=gradient[x*3+2]=128+127*Math.sin(x*Math.PI/180*16);
  ctx.fillStyle="rgb("+r+","+g+","+b+")";
  ctx.fillRect(250-180+x,0,1,10);
}

let mx=250,my=90,r=70,rr=65;

let start=Date.now();

let imgdata=ctx.createImageData(r*2+1,r*2+1);
let data=imgdata.data;

function mix(a,b,w){
  return b+(a-b)*w;
}

for(let y=-r,idx=0;y<=r;y++)
  for(let x=-r;x<=r;x++){
    let d=Math.pow(Math.pow(x,4)+Math.pow(y,4),0.25);
    if(d<=rr){
      data[idx++]=128;
      data[idx++]=128;
      data[idx++]=128;
      data[idx++]=255;
    }else if(d>=r){
      idx+=4;
    }else{
      let gidx=Math.floor(180+Math.atan2(x,y)*180/Math.PI)%360*3;
      if(d<rr+1){
        let w=d-rr;
        data[idx++]=mix(gradient[gidx++],128,w);
        data[idx++]=mix(gradient[gidx++],128,w);
        data[idx++]=mix(gradient[gidx++],128,w);
        data[idx++]=255;
      }else if(d>r-1){
        let w=r-d;
        data[idx++]=mix(gradient[gidx++],255,w);
        data[idx++]=mix(gradient[gidx++],255,w);
        data[idx++]=mix(gradient[gidx++],255,w);
        data[idx++]=255;
      }else{
        data[idx++]=gradient[gidx++];
        data[idx++]=gradient[gidx++];
        data[idx++]=gradient[gidx++];
        data[idx++]=255;
      }
    }
  }

ctx.putImageData(imgdata,mx-r,my-r);

console.log(Date.now()-start);
<canvas id="cnv" width="500" height="170"></canvas>

Upvotes: 4

Related Questions