The Spiteful Octopus
The Spiteful Octopus

Reputation: 319

How can i plot letter around a fabricjs circle

i have a circle added to canvas and then some text i would like wrapped around a circle. here is what i have so far

var circle = new fabric.Circle({
  top: 100,
  left: 100,
  radius: 100,
  fill: '',
  stroke: 'green',
});
canvas.add(circle);

var obj = "some text"

for(letter in obj){             
  var newLetter = new fabric.Text(obj[letter], {
    top: 100,
    left: 100
});

canvas.add(newLetter);
canvas.renderAll();
}

i have a tried a couple other solutions posted around the web but nothing working properly so far with fabric.

Upvotes: 0

Views: 1787

Answers (1)

Blindman67
Blindman67

Reputation: 54041

Circular Text.

I started this answer thinking it would be easy but it turned a little ugly. I rolled it back to a simpler version.

The problems I encountered are basic but there are no simple solutions..

  • Text going around the circle can end up upside down. Not good for reading
  • The spacing. Because the canvas only gives a basic 2D transform I can not scale the top and bottom of the text independently resulting in text that either looks too widely spaced or too squashed.

I have an altogether alternative approch by it is way too heavy for an answer here. It involves a custom scan line render (a GPU hack of sorts) so you may try looking for something along those lines if text quality is paramount.

The problem I encounter were fixed by just ignoring them, always a good solution. LOL

How to render circular text on 2D canvas.

As there is no way to do it in one call I wrote a function that renders each character one at a time. I use ctx.measureText to get the size of the whole string to be drawn and then convert that into an angular pixel size. Then with a little adjustments for the various options, alignment, stretching, and direction (mirroring) I go through each character in the string one at a time, use ctx.measureText to measure it's size and then use ctx.setTransform to position rotate and scale the character, then just call ctx.fillText() rendering just that character.

It is a little slower than just the ctx.fillText()method but then fill text can't draw on circles can it.

Some calculations required.

To workout the angular size of a pixel for a given radius is trivial but often I see not done correctly. Because Javascript works in radians angular pixel size is just

var angularPixelSize = 1 / radius; // simple

Thus to workout what angle some text will occupy on a circle or given radius.

var textWidth = ctx.measureText("Hello").width;
var textAngularWidth = angularPixelSize * textWidth;

To workout the size of a single character.

var text = "This is some text";
var index = 2; // which character
var characterWidth = ctx.measureText(text[index]).width;
var characterAngularWidth = angularPixelSize * textWidth;

So you have the angular size you can now align the text on the circle, either centered, right or left. See the snippet code for details.

Then you need to loop through each character one at a time calculating the transformation, rendering the text, moving the correct angular distance for the next character until done.

var angle = ?? // the start angle
for(var i = 0; i < text.length; i += 1){ // for each character in the string
    var c = text[i]; // get character
    // get character angular width
    var w = ctx.measureText(c).width * angularPixelSize;
    // set the matrix  to align the text. See code under next paragraph 
    ...
    ...
    // matrix set
    ctx.fillText(c,0,0); // as the matrix set the origin just render at 0,0
    angle += w;
}

The fiddly math part is setting the transform. I find it easier to work directly with the transformation matrix and that allows me to mess with scaling etc with out having to use too many transformation calls.

Set transform takes 6 numbers, first two are the direction of the x axis, the next two are the direction of the y axis and the last two are the translation from the canvas origin.

So to get the Y axis. The line from the circle center moving outward for each character we need the angle at which the character is being draw and to reduce misalignment (NOTE reduce not eliminate) the angular width so we can use the character's center to align it.

// assume angle is position and w is character angular width from above code
var xDx = Math.cos(angle + w / 2); // get x part of X axis direction
var xDy = Math.sin(angle + w / 2); // get y part of X axis direction

Now we have the normalised vector that will be the x axis. The character is draw from left to right along this axis. I construct the matrix in one go but I'll break it up below. Please NOTE that I made a boo boo in my snippet code with angles so the code is back to front (X is Y and Y is X) Note that the snippet has the ability to fit text between two angles so I scale the x axis to allow this.

// assume scale is how much the text is squashed along its length. 
ctx.setTransform(
    xDx * scale, xDy * scale, // set the direction and size of a pixel for the X axis
    -xDy, xDx,                // the direction ot the Y axis is perpendicular so switch x and y 
    -xDy * radius + x, xdx * radius + y  // now set the origin by scaling by radius and translating by the circle center
);

Well thats the math and logic to drawing a circular string. I am sorry but I dont use fabric.js so it may or may not have the option. But you can create your own function and render directly to the same canvas as fabric.js, as it does not exclude access. Though it will pay to save and restore the canvas state as fabric.js does not know of the state change.

Below is a snippet showing the above in practice. It is far from ideal but is about the best that can be done quickly using the existing canvas 2D API. Snippet has the two functions for measuring and drawing plus some basic usage examples.

function showTextDemo(){
/** Include fullScreenCanvas.js begin **/
var canvas = document.getElementById("canv");
if(canvas !== null){
    document.body.removeChild(canvas);
}
canvas = (function () {
    // creates a blank image with 2d context
    canvas = document.createElement("canvas");  
    canvas.id = "canv";
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight; 
    canvas.style.position = "absolute";
    canvas.style.top = "0px";
    canvas.style.left = "0px";
    canvas.ctx = canvas.getContext("2d"); 
    document.body.appendChild(canvas);
    return canvas;
} ) ();
var ctx = canvas.ctx;
/** fullScreenCanvas.js end **/





// measure circle text
// ctx: canvas context
// text: string of text to measure
// x,y: position of center
// r: radius in pixels
//
// returns the size metrics of the text
//
// width: Pixel width of text
// angularWidth : angular width of text in radians
// pixelAngularSize : angular width of a pixel in radians
var measureCircleText = function(ctx, text, x, y, radius){
    var textWidth;
    // get the width of all the text
    textWidth = ctx.measureText(text).width;
    return {
        width               :textWidth,
        angularWidth        : (1 / radius) * textWidth,
        pixelAngularSize    : 1 / radius
    }
}

// displays text alon a circle
// ctx: canvas context
// text: string of text to measure
// x,y: position of center
// r: radius in pixels
// start: angle in radians to start. 
// [end]: optional. If included text align is ignored and the text is 
//        scalled to fit between start and end;
// direction 
var circleText = function(ctx,text,x,y,radius,start,end,direction){
    var i, textWidth, pA, pAS, a, aw, wScale, aligned, dir;
    // save the current textAlign so that it can be restored at end
    aligned = ctx.textAlign;
    
    dir = direction ? 1 : -1;
    // get the angular size of a pixel in radians
    pAS = 1 / radius;
    
    // get the width of all the text
    textWidth = ctx.measureText(text).width;
    
    // if end is supplied then fit text between start and end
    if(end !== undefined){
        pA = ((end - start) / textWidth) * dir;
        wScale = (pA / pAS) * dir;
    }else{ // if no end is supplied corret start and end for alignment
        pA = -pAS * dir;
        wScale = -1 * dir;
        switch(aligned){
            case "center": // if centered move around half width
                start -= pA * (textWidth / 2);
                end = start + pA * textWidth;
                break;
            case "right":
                end = start;
                start -= pA * textWidth;
                break;
            case "left":
                end = start + pA * textWidth;
        }
    }

    // some code to help me test. Left it here incase someone wants to underline
    // rmove the following 3 lines if you dont need underline
    ctx.beginPath();
    ctx.arc(x,y,radius,end,start,end>start?true:false);
    ctx.stroke();

    ctx.textAlign = "center"; // align for rendering

    a = start;  // set the start angle
    for (var i = 0; i < text.length; i += 1) {  // for each character
        // get the angular width of the text
        aw = ctx.measureText(text[i]).width * pA;
        var xDx = Math.cos(a + aw / 2); // get the yAxies vector from the center x,y out
        var xDy = Math.sin(a + aw / 2);
        if (xDy < 0) {  // is the text upside down. If it is flip it
            // sets the transform for each character scaling width if needed
            ctx.setTransform(-xDy * wScale, xDx * wScale,-xDx,-xDy, xDx * radius + x,xDy * radius + y);
        }else{
            ctx.setTransform(-xDy * wScale, xDx * wScale, xDx, xDy, xDx * radius + x, xDy * radius + y);
        }
        // render the character
        ctx.fillText(text[i],0,0);

        a += aw;

    }
    ctx.setTransform(1,0,0,1,0,0);
    ctx.textAlign = aligned;
}


// set up canvas
var w = canvas.width;
var h = canvas.height;
var cw = w / 2;   // centers
var ch = h / 2;
var rad = (h / 2) * 0.9;  // radius
// clear
ctx.clearRect(0, 0, w, h)
// the font
var fontSize = Math.floor(h/20);
if(h < 400){
   var fontSize = 10;
}
ctx.font = fontSize + "px verdana";
// base settings
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#666";
ctx.strokeStyle = "#666";

// Text under stretched
circleText(ctx, "Test of circular text rendering", cw, ch, rad, Math.PI, 0, true);
// Text over stretchered
ctx.fillStyle = "Black";
circleText(ctx, "This text is over the top", cw, ch, rad, Math.PI, Math.PI * 2, true);
 
// Show centered text
rad -= fontSize + 4;
ctx.fillStyle = "Red";
// Use measureCircleText to get angular size
var tw = measureCircleText(ctx, "Centered", cw, ch, rad).angularWidth;
// centered bottom and top
circleText(ctx, "Centered", cw, ch, rad, Math.PI / 2, undefined, true);
circleText(ctx, "Centered", cw, ch, rad, -Math.PI * 0.5, undefined, false);
// left align bottom and top
ctx.textAlign = "left";
circleText(ctx, "Left Align", cw, ch, rad, Math.PI / 2 - tw * 0.6, undefined, true);
circleText(ctx, "Left Align Top", cw, ch, rad, -Math.PI / 2 + tw * 0.6, undefined, false);
// right align bottom and top
ctx.textAlign = "right";
circleText(ctx, "Right Align", cw, ch, rad, Math.PI / 2 + tw * 0.6, undefined, true);
circleText(ctx, "Right Align Top", cw, ch, rad, -Math.PI / 2 - tw * 0.6, undefined, false);

// Show base line at middle
ctx.fillStyle = "blue";
rad -= fontSize + fontSize;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
circleText(ctx, "Baseline Middle", cw, ch, rad, Math.PI / 2, undefined, true);
circleText(ctx, "Baseline Middle", cw, ch, rad, -Math.PI / 2, undefined, false);

// show baseline at top
ctx.fillStyle = "Green";
rad -= fontSize + fontSize;
ctx.textAlign = "center";
ctx.textBaseline = "top";
circleText(ctx, "Baseline top", cw, ch, rad, Math.PI / 2, undefined, true);
circleText(ctx, "Baseline top", cw, ch, rad, -Math.PI / 2, undefined, false);
}

showTextDemo();
window.addEventListener("resize",showTextDemo);

Upvotes: 4

Related Questions