我试图在打字时让文本适合一个圆圈,如下所示:
我尝试按照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](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>
</>
);
}
我也尝试遵循 @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](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>
</>
);
}
这是我的尝试:
<!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>
我使用了一些几何图形来计算每行文本的边界框。 Canvas 的measureText() 在处理文本中的前导/尾随空格时有些出乎意料,因此需要牢记或在更好地理解后进行改进。
代码按照设置的字体大小逐行绘制,除非有一条线不适合圆圈。在这种情况下,队列将被清理,并以减小的字体大小重试代码。这是线性下降,但如果找到停止条件,则可以使用二分搜索以获得更好的性能。
getHighestSuitableForTextPoint() 中的+1 像素可防止舍入错误,因为浮点精度对于此应用程序来说不是最佳选择,因此文本行会闪烁。