I'm trying to have text fit a circle while typing, something like this:
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>
</>
);
}
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.
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).
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 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 fontDEFAULT_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 sizeMAX_SCALE
The max scale the text can be rendered at.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>