Phi*_*aro 5 html javascript svg html5-canvas reactjs
我试图在打字时让文本适合一个圆圈,如下所示:
我尝试遵循Mike Bostock 的将文本调整为圆形教程,但到目前为止失败了,这是我可怜的尝试:
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](/sf/answers/4140044961/)
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>
</>
);
}
Run Code Online (Sandbox Code Playgroud)
我还尝试遵循@markE 2013 年的回答。但文本似乎并没有随着圆的半径而缩放,在该示例中是相反的,据我所知,半径被缩放以适合文本。而且,由于某种原因,更改示例文本会产生text is undefined错误,我不知道为什么。
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](/sf/answers/4140044961/)
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>
</>
);
}
Run Code Online (Sandbox Code Playgroud)
由于您没有正在运行的示例,因此我没有尝试查找代码中的错误(如果有)。相反,我使用画布和 2D API 编写了一个示例。
主要问题是在哪里放置换行符。有很多方法可以做到这一点。最好的方法很复杂,涉及尝试换行符的组合并对结果进行测量和评分,然后保持最适合所需约束的布局。这超出了 SO 答案的范围(太主观)。
该示例将字符串分成几行。如果当前行长度大于计算的每行平均字符数,则会插入换行符。
行数将是字数的平方根(如果最大缩放字体无法将单行放入圆圈中)。
创建线后,示例将测量每条线并计算边界半径。边界半径用于设置适合圆的比例。
该函数fitCircleText(ctx, cx, cy, radius, inset = 20, text = "", font = "arial", circleColor = "#C45", fontColor = "#EEE")渲染圆并拟合并渲染文本。
ctx渲染的 2D 上下文
cx, cy圆心(以像素为单位)
radius圆半径(以像素为单位)
inset距圆边缘的近似插入距离以保留文本。(警告小值或负值将导致文本溢出圆圈)
text要渲染的文本
font字体系列(不包括由函数添加的字体大小)
circleColor, fontColor颜色
还有一些相关的常量。
LINE_CUT在创建新行之前更改最小行长度的值。必须是大于2的值。值越大,添加的换行符越多。DEFAULT_FONT_SIZE以像素为单位的字体大小DEFAULT_FONT_HEIGHT调整高度,因为并非所有浏览器都允许您测量字体高度。例如,字体高度是字体大小的 1.2 倍MAX_SCALE文本可以渲染的最大比例。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);
}
}Run Code Online (Sandbox Code Playgroud)
<input type="text" id="textInputEl" size="60"></input></br>
<canvas id="canvas"></canvas>Run Code Online (Sandbox Code Playgroud)