使用 React 在 HTML Canvas 中打字时将文本调整为圆形(通过缩放)

Phi*_*aro 5 html javascript svg html5-canvas reactjs

我试图在打字时让文本适合一个圆圈,如下所示:

示例图 1

我尝试遵循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)

Bli*_*n67 7

举例来说

由于您没有正在运行的示例,因此我没有尝试查找代码中的错误(如果有)。相反,我使用画布和 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)