Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Fit Text to Circle (With Scaling) in HTML Canvas, while Typing, with React

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>
    </>
  );
}
like image 587
Philippe Fanaro Avatar asked Oct 26 '25 08:10

Philippe Fanaro


2 Answers

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>
like image 172
Blindman67 Avatar answered Oct 28 '25 21:10

Blindman67


Here I combine Wrapping Text to Fit Shaped Containers with CSS with an answer to the question: "How can I convert an HTML element to a canvas element?".

In the first snippet you will find the plain example of fitting text into the shape in HTML and CSS. The shape is made using the CSS property shape-outside.

document.forms.form01.text.addEventListener('input', e => {
  let span = document.getElementById('output');
  span.textContent = e.target.value;
  let div = span.closest('div');

  let sizeok = false;
  let size = 1;
  while (!sizeok) {
    size++;
    div.style.fontSize = `${size}px`;
    let spanRect = span.getBoundingClientRect();
    let divRect = div.getBoundingClientRect();
    if (spanRect.height > divRect.height || size > 80) {
      size--;
      div.style.fontSize = `${size}px`;
      sizeok = true;
    }
  }
});
div {
  width: 200px;
  height: 200px;
  border-radius: 100px;
  background-color: orange;
  text-align: center;
}

div:before {
  content: '';
  height: 100%;
  width: 50%;
  float: left;
  shape-outside: polygon( 0 0, 100% 0, 60% 4%, 40% 10%, 20% 20%, 10% 28.2%, 5% 34.4%, 0 50%, 5% 65.6%, 10% 71.8%, 20% 80%, 40% 90%, 60% 96%, 100% 100%, 0 100%);
}

span:before {
  content: '';
  height: 100%;
  width: 50%;
  float: right;
  shape-outside: polygon( 100% 0, 0 0, 40% 4%, 60% 10%, 80% 20%, 90% 28.2%, 95% 34.4%, 100% 50%, 95% 65.6%, 90% 71.8%, 80% 80%, 60% 90%, 40% 96%, 0 100%, 100% 100%);
}
<form name="form01">
  <input type="text" name="text">
</form>

<div style="font-size: 1px;"><span id="output"></span></div>

And then the full solution where the HTML is a foreign object in a SVG element and the SVG is then placed in a canvas element.

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

resetCanvas();

document.forms.form01.text.addEventListener('input', e => {
  let span = document.getElementById('output');
  span.textContent = e.target.value;
  let div = span.closest('div');

  let sizeok = false;
  let size = 1;
  while (!sizeok) {
    size++;
    div.style.fontSize = `${size}px`;
    let spanRect = span.getBoundingClientRect();
    let divRect = div.getBoundingClientRect();
    if (spanRect.height > divRect.height || size > 30) {
      size--;
      div.style.fontSize = `${size}px`;
      sizeok = true;
    }
  }
});

document.forms.form01.text.addEventListener('input', e => {
  let img = new Image();
  let serializer = new XMLSerializer();
  let svgElement = document.querySelector('svg');
  var svgData = new XMLSerializer().serializeToString(svgElement);
  img.addEventListener('load', e => {
    ctx.drawImage(e.target, 0, 0, 200, 200);
  });
  resetCanvas();
  img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
});

function resetCanvas() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.restore();
  ctx.fillStyle = 'black';
  ctx.arc(100, 100, 100, 0, 2 * Math.PI);
  ctx.fill();
}
svg {
  visibility: hidden;
  position: absolute;
}
<form name="form01">
  <input type="text" name="text">
</form>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="200" height="200">
  <style>
    div {
      width: 100px;
      height: 100px;
      text-align: center;
      color: red;
    }
    div:before {
      content: '';
      height: 100%;
      width: 50%;
      float: left;
      shape-outside: polygon( 0 0, 100% 0, 60% 4%, 40% 10%, 20% 20%, 10% 28.2%, 5% 34.4%, 0 50%, 5% 65.6%, 10% 71.8%, 20% 80%, 40% 90%, 60% 96%, 100% 100%, 0 100%);
    }
    span:before {
      content: '';
      height: 100%;
      width: 50%;
      float: right;
      shape-outside: polygon( 100% 0, 0 0, 40% 4%, 60% 10%, 80% 20%, 90% 28.2%, 95% 34.4%, 100% 50%, 95% 65.6%, 90% 71.8%, 80% 80%, 60% 90%, 40% 96%, 0 100%, 100% 100%);
    }
  </style>
  <foreignObject width="100" height="100">
    <div xmlns="http://www.w3.org/1999/xhtml" style="font-size:20px">
      <span id="output"></span>
    </div>
  </foreignObject>
</svg>

<canvas id="canvas" width="200" height="200"></canvas>
like image 24
chrwahl Avatar answered Oct 28 '25 21:10

chrwahl



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!