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]()
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]()
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>
</>
);
}
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./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./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>
</>
);
}
Share
Improve this question
edited Dec 3, 2023 at 11:30
psygo
asked Nov 30, 2023 at 14:25
psygopsygo
7,6837 gold badges61 silver badges107 bronze badges
9
- The link to "Mike Bostock's tutorial" seems broken or dead, it says "You either don’t have permission to view this notebook, or it doesn’t exist". – l3l_aze Commented Dec 2, 2023 at 21:44
- Nicely spotted, just fixed it. I think the link was pointing to my fork of that tutorial. – psygo Commented Dec 2, 2023 at 23:19
- Is it important that this is done in HTML canvas? – chrwahl Commented Dec 3, 2023 at 9:05
- 1 Alternative would be HTLM markup, CSS and JavaScript. – chrwahl Commented Dec 3, 2023 at 12:53
- 1 So, perhaps removing the svg tag from your question would avoid confusion... – Lee Taylor Commented Dec 3, 2023 at 15:45
3 Answers
Reset to default 7 +100By 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 plex and involve trying binations 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 rendercx, cy
The center of the circle in pixelsradius
The circle radius in pixelsinset
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 renderfont
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.
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!
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745274466a4619951.html
评论列表(0条)