skip to Main Content

I’m trying to have text fit a circle while typing, something like this:

Example Image 1

I’ve tried following Mike Bostock’s Fit Text to Circle tutorial, but failed so far, here’s my pitiful attempt:

import React, { useEffect, useRef, useState } from "react";

export const TwoPI = 2 * Math.PI;

export function setupGridWidthHeightAndScale(
  width: number,
  height: number,
  canvas: HTMLCanvasElement
) {
  canvas.style.width = width + "px";
  canvas.style.height = height + "px";

  // Otherwise we get blurry lines
  // Referenece: [Stack Overflow - Canvas drawings, like lines, are blurry](https://stackoverflow.com/a/59143499/4756173)
  const scale = window.devicePixelRatio;

  canvas.width = width * scale;
  canvas.height = height * scale;

  const canvasCtx = canvas.getContext("2d")!;

  canvasCtx.scale(scale, scale);
}


type CanvasProps = {
  width: number;
  height: number;
};

export function TextInCircle({
  width,
  height,
}: CanvasProps) {
  const [text, setText] = useState("");

  const canvasRef = useRef<HTMLCanvasElement>(null);

  function getContext() {
    const canvas = canvasRef.current!;
    return canvas.getContext("2d")!;
  }

  useEffect(() => {
    const canvas = canvasRef.current!;
    setupGridWidthHeightAndScale(width, height, canvas);

    const ctx = getContext();

    // Background
    ctx.fillStyle = "black";
    ctx.fillRect(0, 0, width, height);

    // Circle
    ctx.beginPath();
    ctx.arc(width / 2, height / 2, 100, 0, TwoPI);
    ctx.closePath();

    // Fill the Circle
    ctx.fillStyle = "white";
    ctx.fill();
  }, [width, height]);

  function handleChange(
    e: React.ChangeEvent<HTMLInputElement>
  ) {
    const newText = e.target.value;
    setText(newText);

    // Split Words
    const words = text.split(/s+/g); // To hyphenate: /s+|(?<=-)/
    if (!words[words.length - 1]) words.pop();
    if (!words[0]) words.shift();

    // Get Width
    const lineHeight = 12;
    const targetWidth = Math.sqrt(
      measureWidth(text.trim()) * lineHeight
    );

    // Split Lines accordingly
    const lines = splitLines(targetWidth, words);

    // Get radius so we can scale
    const radius = getRadius(lines, lineHeight);

    // Draw Text
    const ctx = getContext();

    ctx.textAlign = "center";
    ctx.fillStyle = "black";
    for (const [i, l] of lines.entries()) {
      // I'm totally lost as to how to proceed here...
      ctx.fillText(
        l.text,
        width / 2 - l.width / 2,
        height / 2 + i * lineHeight
      );
    }
  }

  function measureWidth(s: string) {
    const ctx = getContext();
    return ctx.measureText(s).width;
  }

  function splitLines(
    targetWidth: number,
    words: string[]
  ) {
    let line;
    let lineWidth0 = Infinity;
    const lines = [];

    for (let i = 0, n = words.length; i < n; ++i) {
      let lineText1 =
        (line ? line.text + " " : "") + words[i];

      let lineWidth1 = measureWidth(lineText1);

      if ((lineWidth0 + lineWidth1) / 2 < targetWidth) {
        line!.width = lineWidth0 = lineWidth1;
        line!.text = lineText1;
      } else {
        lineWidth0 = measureWidth(words[i]);
        line = { width: lineWidth0, text: words[i] };
        lines.push(line);
      }
    }
    return lines;
  }

  function getRadius(
    lines: { width: number; text: string }[],
    lineHeight: number
  ) {
    let radius = 0;

    for (let i = 0, n = lines.length; i < n; ++i) {
      const dy =
        (Math.abs(i - n / 2 + 0.5) + 0.5) * lineHeight;

      const dx = lines[i].width / 2;

      radius = Math.max(
        radius,
        Math.sqrt(dx ** 2 + dy ** 2)
      );
    }

    return radius;
  }

  return (
    <>
      <input type="text" onChange={handleChange} />

      <canvas ref={canvasRef}></canvas>
    </>
  );
}

I’ve also tried to follow @markE’s answer from 2013. But the text doesn’t seem to be made to scale with the circle’s radius, it’s the other way around in that example, with the radius being scaled to fit the text, as far as I was able to understand. And, for some reason, changing the example text yields a text is undefined error, I have no idea why.

import React, { useEffect, useRef, useState } from "react";

export const TwoPI = 2 * Math.PI;

export function setupGridWidthHeightAndScale(
  width: number,
  height: number,
  canvas: HTMLCanvasElement
) {
  canvas.style.width = width + "px";
  canvas.style.height = height + "px";

  // Otherwise we get blurry lines
  // Referenece: [Stack Overflow - Canvas drawings, like lines, are blurry](https://stackoverflow.com/a/59143499/4756173)
  const scale = window.devicePixelRatio;

  canvas.width = width * scale;
  canvas.height = height * scale;

  const canvasCtx = canvas.getContext("2d")!;

  canvasCtx.scale(scale, scale);
}

type CanvasProps = {
  width: number;
  height: number;
};

export function TextInCircle({
  width,
  height,
}: CanvasProps) {
  const [typedText, setTypedText] = useState("");

  const canvasRef = useRef<HTMLCanvasElement>(null);

  function getContext() {
    const canvas = canvasRef.current!;
    return canvas.getContext("2d")!;
  }

  useEffect(() => {
    const canvas = canvasRef.current!;
    setupGridWidthHeightAndScale(width, height, canvas);
  }, [width, height]);

  const textHeight = 15;
  const lineHeight = textHeight + 5;
  const cx = 150;
  const cy = 150;
  const r = 100;

  function handleChange(
    e: React.ChangeEvent<HTMLInputElement>
  ) {
    const ctx = getContext();

    const text = e.target.value; // This gives out an error
    // "'Twas the night before Christmas, when all through the house,  Not a creature was stirring, not even a mouse.  And so begins the story of the day of";

    const lines = initLines();
    wrapText(text, lines);

    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, Math.PI * 2, false);
    ctx.closePath();
    ctx.strokeStyle = "skyblue";
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  // pre-calculate width of each horizontal chord of the circle
  // This is the max width allowed for text

  function initLines() {
    const lines: any[] = [];

    for (let y = r * 0.9; y > -r; y -= lineHeight) {
      let h = Math.abs(r - y);

      if (y - lineHeight < 0) {
        h += 20;
      }

      let length = 2 * Math.sqrt(h * (2 * r - h));

      if (length && length > 10) {
        lines.push({
          y: y,
          maxLength: length,
        });
      }
    }

    return lines;
  }

  // draw text on each line of the circle

  function wrapText(text: string, lines: any[]) {
    const ctx = getContext();

    let i = 0;
    let words = text.split(" ");

    while (i < lines.length && words.length > 0) {
      let line = lines[i++];

      let lineData = calcAllowableWords(
        line.maxLength,
        words
      );

      ctx.fillText(
        lineData!.text,
        cx - lineData!.width / 2,
        cy - line.y + textHeight
      );

      words.splice(0, lineData!.count);
    }
  }

  // calculate how many words will fit on a line

  function calcAllowableWords(
    maxWidth: number,
    words: any[]
  ) {
    const ctx = getContext();

    let wordCount = 0;
    let testLine = "";
    let spacer = "";
    let fittedWidth = 0;
    let fittedText = "";

    const font = "12pt verdana";
    ctx.font = font;

    for (let i = 0; i < words.length; i++) {
      testLine += spacer + words[i];
      spacer = " ";

      let width = ctx.measureText(testLine).width;

      if (width > maxWidth) {
        return {
          count: i,
          width: fittedWidth,
          text: fittedText,
        };
      }

      fittedWidth = width;
      fittedText = testLine;
    }
  }

  return (
    <>
      <input type="text" onChange={handleChange} />

      <canvas ref={canvasRef}></canvas>
    </>
  );
}

2

Answers


  1. Here is my attempt:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Text in circle</title>
        <style>
            body {
                font-family: "News Cycle", Verdana, Arial, sans-serif;
            }
        </style>
    </head>
    <body>
    <div>
        <label for="text">Text input</label>
        <textarea id="text" type="text" oninput="onChange('text')">Text meant
    to fit within
    circle</textarea>
    </div>
    <div>
        <label for="radius">Circle Radius</label>
        <input id="radius" type="range" value="150" max="500" oninput="onChange('radius', Number)"/>
    </div>
    <div>
        <label for="font">Font Size</label>
        <input id="font" type="range" value="60" max="500" oninput="onChange('font', Number)"/>
    </div>
    <div>
        <label for="lineHeight">Line Height in 1/100</label>
        <input id="lineHeight" type="range" value="150" min="100" max="1000" oninput="onChange('lineHeight', Number)"/>
    </div>
    <div>
        <label for="canvas_height">Canvas height</label>
        <input id="canvas_height" type="range" value="500" max="5000" oninput="onChange('canvas_height', Number)"/>
    </div>
    <div>
        <label for="canvas_width">Canvas width</label>
        <input id="canvas_width" type="range" value="600" max="5000" oninput="onChange('canvas_width', Number)"/>
    </div>
    <div>
        <canvas id="canvas"></canvas>
    </div>
    <script>
        const values = {
            radius: Number(document.getElementById('radius').value),
            font: Number(document.getElementById('font').value),
            canvas_height: Number(document.getElementById('canvas_height').value),
            canvas_width: Number(document.getElementById('canvas_width').value),
            text: document.getElementById('text').value,
            lineHeight: Number(document.getElementById('lineHeight').value)
        };
        const canvas = document.getElementById('canvas');
        /** @type {CanvasRenderingContext2D} */
        const ctx = canvas.getContext('2d');
    
        redraw();
    
        function onChange(id, transform = i => i) {
            setValue(id, transform);
            redraw();
        }
    
        function setValue(id, transform = i => i) {
            values[id] = transform(document.getElementById(id).value);
        }
    
        function fitTextIntoCircle(fontSize = values.font) {
            if (fontSize < 6) {
                return;
            }
            ctx.font = `${fontSize}px serif`;
            const circleObj = {x: values.radius, y: values.radius, r: values.radius};
            const measurement = ctx.measureText(values.text)
            const height = measurement.actualBoundingBoxAscent + measurement.actualBoundingBoxDescent;
            let queue = [];
            let isFail = false;
    
            values.text.split('n').forEach((line) => {
                const measurement = ctx.measureText(line)
                const box = {
                    width: measurement.actualBoundingBoxRight + measurement.actualBoundingBoxLeft,
                    height
                };
                const x = values.radius - (box.width) / 2;
                let y;
                y = Math.max(
                    getHighestSuitableForTextPoint(box.width),
                    (queue[queue.length - 1]?.y + values.lineHeight / 100 * queue[queue.length - 1]?.box.height) || 0
                );
    
                const topLeftIsWithinCircle = pixelBelongsToCircle(circleObj, {x, y});
                const bottomLeftIsWithinCircle = pixelBelongsToCircle(circleObj, {x, y: y + box.height});
    
                if (topLeftIsWithinCircle && bottomLeftIsWithinCircle) {
                    queue.push({x, y, box, line})
                } else {
                    isFail = true;
                }
            })
            if (isFail) {
                clear();
                return fitTextIntoCircle(fontSize - 1);
            }
            queue.forEach(({x, y, box, line}) => {
                //ctx.fillStyle = "grey";
                //ctx.fillRect(x, y, box.width, box.height)
                ctx.fillStyle = "red";
                ctx.fillText(`${line}`, x, y + box.height);
    
            })
    
        }
    
        function drawCircle() {
            ctx.beginPath();
            ctx.arc(values.radius, values.radius, values.radius, 0, Math.PI * 2);
            ctx.closePath();
            ctx.fillStyle = "black";
            ctx.fill();
        }
    
        function drawRect() {
            canvas.width = values.canvas_width;
            canvas.height = values.canvas_height;
            ctx.fillStyle = "white";
            ctx.fillRect(0, 0, values.canvas_width, values.canvas_height);
            ctx.fill();
        }
    
        /**
         * @param {{x: number, y:number, r: number }} circle
         * @param {{ x: number, y: number  }} pixel
         */
        function pixelBelongsToCircle(circle, pixel) {
            // (x - a)^2 + (y - b)^2 <= r^2
            return (pixel.x - circle.x) ** 2 + (pixel.y - circle.y) ** 2 <= circle.r ** 2;
        }
    
        function getHighestSuitableForTextPoint(width) {
            const r = values.radius;
            return 1 + r - (r ** 2 - (width / 2) ** 2) ** 0.5;
        }
    
        function clear() {
            drawRect();
            drawCircle();
        }
    
        function redraw() {
            clear();
            fitTextIntoCircle()
        }
    </script>
    </body>
    </html>

    I’ve used some geometry to calculate the bounding box for each line of text.
    Canvas’ measureText() works somewhat unexpected with leading/trailing whitespaces in text, so something to keep in mind or to improve once understood better.

    Code queues drawing line by line with the set font size, unless there is a line which doesn’t fit into the circle. In that case queue is cleaned and code retries with a reduced font size. This is a linear descent, but a binary search may be used for better performance if stop condition is found.

    +1 pixel in getHighestSuitableForTextPoint() prevents rounding errors, where text line would flicker because float precision is not the best for this application.

    The way getHighestSuitableForTextPoint() works is by solving a geometry problem, where you have 3 points on a circle with radiuses going through lower left corner of the bounding box, top left corner of the bounding box, and the middle of the bounding box. The task is to find the coordinates of the bottom left corner of the bounding box, because that’s how canvas draws text: from the bottom left corner.
    Then I take this point and compare it to a (previous line bottom left point + line height), and take whatever is lower. This way I prevent text overlapping.

    Login or Signup to reply.
  2. By example

    As you did not have a running example I did not try to find the bug if any in your code. Rather I wrote a example using the canvas and 2D API.

    Justifying text

    The main problem is where to put the line breaks. There are many ways to do this. The best are complex and involve trying combinations of line breaks and measuring and scoring the result and then keeping the layout that best fits the desired constraints. This is beyond the scope of an SO answer (too subjective).

    Example A square box to fit a round circle

    The example breaks a string into lines. The line breaks are inserted if the current line length is greater than the calculated mean character count per line.

    The number of lines will be the square root of the number of words (If max scaled font can not fit a single line to the circle).

    Once the lines have been created the example measures each line and calculates the bounding radius. The bounding radius is used to set a scale that will fit the circle.

    The code writing service result.

    The function fitCircleText(ctx, cx, cy, radius, inset = 20, text = "", font = "arial", circleColor = "#C45", fontColor = "#EEE") renders the circle and fits and renders the text.

    • ctx the 2D context on which to render

    • cx, cy The center of the circle in pixels

    • radius The circle radius in pixels

    • inset An approximate inset distance from edge of circle to keep text. (Warning small or negative values will result in text overflowing the circle)

    • text The text to render

    • font the font family (do not include the font size as that is added by the function)

    • circleColor, fontColor The colors

    There are some constants that relate as well.

    • LINE_CUT A value that changes min line length befor a new line is created. Must be a value greater than 2. The bigger the value the more line breaks will be added.
    • DEFAULT_FONT_SIZE In pixels the size of the font
    • DEFAULT_FONT_HEIGHT Adjustment for height as not all browsers let you measure font height. In example the font height is 1.2 times the font size
    • MAX_SCALE The max scale the text can be rendered at.

    Example

    const SIZE = 400;             // In pixels. Size of canvas all else is scaled to fit
    canvas.width = SIZE;
    canvas.height = SIZE;
    const ctx = canvas.getContext("2d");
    const RADIUS = SIZE * 0.45;
    const INSET = SIZE * 0.015;
    const CENTER_X = SIZE * 0.5;
    const CENTER_Y = SIZE * 0.5;
    const LINE_CUT = 3;            // Must be 2 or greater. This affects when a new line is created. 
                                   // The larger the value the more often a new line will be added.
    const DEFAULT_FONT_SIZE = 64;  // In pixels. Font is scaled to fit so that font size remains constant
    const MAX_SCALE = 2;           // Max font scale used
    const DEFAULT_FONT_HEIGHT = DEFAULT_FONT_SIZE * 1.2;
    
    textInputEl.focus();
    textInputEl.addEventListener("input", (e) => { 
        stop = true;
        fitCircleText(ctx, CENTER_X, CENTER_Y, RADIUS, INSET, textInputEl.value) 
    });
    
    /* DEMO CODE */
    const wordList = words = "Hello! This snippet shows how to wrap and fit text inside a circle. It could be useful for labelling a bubble chart. You can edit the text above, or read the answer and snippet code to learn how it works! 😎".split(" ");
    var wordCount = 0, stop = false;
    addWord();
    
    function addWord() {
        if (!stop) {
            textInputEl.value += wordList[wordCount++] + " ";
            fitCircleText(ctx, CENTER_X, CENTER_Y, RADIUS, INSET, textInputEl.value);
            if (wordCount < wordList.length) {
                setTimeout(addWord, 200 + Math.random() * 500);
            } else {
                stop = true;
            }
        }
    }
    /* DEMO CODE END */
    
    
    function fillWord(ctx, word, x, y) { // render a word
        ctx.fillText(word.text, x, y)
        return x + word.width;
    }
    function fillLine(ctx, words, line) {   // render a line
        var idx = line.from;
        var x = line.x;
        while (idx < line.to) {
            const word = words[idx++];
            x = fillWord(ctx, word, x, line.y);
            x += word.space;
        }
    }
    function getCharWidth(words, fromIdx, toIdx) { // in characters
        var width = 0;
        while (fromIdx < toIdx) { width += words[fromIdx].text.length + (fromIdx++ < toIdx ? 1 : 0); }
        return width;         
    }
    function getWordsWidth(words, line) { // in pixels
        var width = 0;
        var idx = line.from;
        while (idx < line.to) { width += words[idx].width + (idx++ < line.to ? words[idx - 1].space : 0); }
        return width;         
    }
    
    function fitCircleText(ctx, cx, cy, radius, inset = 20, text = "", font = "arial", circleColor = "#C45", fontColor = "#EEE") {
        var x, y, totalWidth, scale, line;
        ctx.fillStyle = circleColor;
        ctx.beginPath();
        ctx.arc(cx, cy, radius, 0, Math.PI * 2);
        ctx.fill();
        text = (text?.toString?.() ?? "").trim();
        if (text) {
            ctx.fillStyle = fontColor;
            ctx.font = DEFAULT_FONT_SIZE + "px " + font;
            ctx.textAlign = "left";
            ctx.textBaseline = "middle";
            const spaceWidth = ctx.measureText(" ").width;
            const words = text.split(" ").map((text, i, words) => (
                {width: ctx.measureText(text).width, text, space: (i < words.length - 1) ? spaceWidth : 0}               
            ));
            const lines = [];
            const totalWidth = ctx.measureText(text).width;   
            circleWidth = (radius - inset) * 2;
            scale = Math.min(MAX_SCALE, circleWidth / totalWidth);
            const wordCount = words.length;
    
            // If single line can not fit 
            if (scale < MAX_SCALE && words.length > 1) {   // split lines and get bounding radius
                let lineCount = Math.ceil(Math.sqrt(words.length)) ;
                let lineIdx = 0;
                let fromWord = 0;
                let toWord = 1;
                
                // get a set of lines approx the same character count
                while (fromWord < wordCount) {
                    let lineCharCount = getCharWidth(words, fromWord, toWord);
                    while (toWord < wordCount && lineCharCount < text.length / (lineCount + LINE_CUT)) {
                        lineCharCount = getCharWidth(words, fromWord, toWord++);
                    }
                    lines.push(line = {x: 0, y: 0, idx: lineIdx++, from: fromWord, to: toWord});                 
                    fromWord = toWord;
                    toWord = fromWord + 1;                           
                }
                
                // find the bounding circle radius of lines
                let boundRadius = -Infinity;
                lineIdx = 0;         
                for (const line of lines) {
                    const lineWidth = getWordsWidth(words, line) * 0.5;
                    const lineHeight = (-(lineCount - 1) * 0.5 + lineIdx) * DEFAULT_FONT_HEIGHT; // to middle of line
                    const lineTop = lineHeight - DEFAULT_FONT_HEIGHT * 0.5;
                    const lineBottom = lineHeight + DEFAULT_FONT_HEIGHT * 0.5;
                    boundRadius = Math.max(Math.hypot(lineWidth, lineTop), Math.hypot(lineWidth, lineBottom), boundRadius);
                    lineIdx ++;            
                }
                
                // use bounding radius to scale and then fit each line
                scale = (radius - inset) / (boundRadius + inset);
                lineIdx = 0;
                for (const line of lines) {
                    line.y = (-(lines.length - 1) * 0.5  + lineIdx) * DEFAULT_FONT_HEIGHT;
                    line.x = -getWordsWidth(words, line) * 0.5;
                    lineIdx ++;
                }
            } else {
                lines.push({x: 0, y: 0, from: 0, to: words.length});
                lines[0].x = -getWordsWidth(words, lines[0]) * 0.5;
            }
    
            // Scale and render all lines
            ctx.setTransform(scale, 0, 0, scale, cx, cy);
            lines.forEach(line => { fillLine(ctx, words, line); });
            
            // restore default
            ctx.setTransform(1, 0, 0, 1, 0, 0);
            
        }
    }
    <input type="text" id="textInputEl" size="60"></input></br>
    <canvas id="canvas"></canvas>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search