Search code examples
reactjsreact-konvakonva

How do I Create a Vertical Arrow with Text Annotation with Dynamic Text Positioning in Konva?


I'm trying to draw some geometric shapes in the user interface via Konva and react-konva. One basic element is a vertical arrow with annotated text to show the dimension of a shape, as shown below:

Screenshot of Vertical Arrow

It's created by the following code snippet, which was my first attempt at implementing it.

import {Stage, Layer, Group, Rect, Arrow, Text} from 'react-konva';

function AnnotatedVerticalArrow({x, y0, y1, text})
{
  return (
    <Group>
      <Arrow
        points={[x, y0, x, y1]}
        pointerAtBeginning={true}
        pointerAtEnding={true}
        fill="black"
        stroke="black"
      />  
      <Text
        x={x - 35} 
        y={(y1 - y0) / 2}
        text={text}
        fontSize={12}
      />  
    </Group>
  )
}

function App() {
  return (
    <Stage width={window.innerWidth} height={window.innerHeight}>
      <Layer>
        <AnnotatedVerticalArrow
          x={50}
          y0={0}
          y1={100}
          text="2.54"
        />
      </Layer>
    </Stage>
  );  
}

export default App;

However, I don't know how to correctly position the text, and this implementation has several problems:

  1. The text label is not centered properly, as the origin of the text label is not the center of the text, but at the corner. In order to find its center, the dimension of the text is required.

  2. The spacing between the arrow and the text label is hardcoded for a font with size 12. Changing font size, using shorter text, or using longer text breaks the user interface due to excessive or insufficient spacing.

How do I calculate the size of a Text and use its dimension during rendering?

I've seen an extensive discussion about text size calculation at GitHub repository konvajs/react-konva, the suggested solution is calculating the size in componentDidMount(), then using the size to change the state of the element and force a rerender. But the example code needs an update since it's written for React classes, not React hooks. The example is also unclear about how to calculate the dimension with different font sizes. Another proposed using measureText, but one commenter claimed the result was buggy. Yet another developer suggested an extremely complicated workaround, how it works is not obvious.


Solution

  • I've found the answer by combining the answer to the question How to get a react component's size (height/width) before render? and the example code in GitHub repo konvajs/react-konva's Issue #6.

    The solution is to:

    1. Represent the calculated size of the text as a state, calculatedTextSize via useState(), initialize the default values to 0.
    2. Create a reference to the Text as textRef via useRef().
    3. Set the property ref of the Text we're returning to textRef, so this component is associated with the reference and becomes accessible in code.
    4. Calculate the size of the Text by accessing calculatedTextSize.width and calculatedTextSize.height, as if it's already calculated.
    5. Perform the size calculation in React.useLayoutEffect() callback. This function is executed when the first render pass is completed. Within the function, call the measureSize() method of the Text to obtain the result, then pass the result by changing the state via setCalculatedTextSize().
    6. ...which triggers a re-render, so the new size is used.

    The new implementation of AnnotatedVerticalArrow() is:

    import React from 'react';
    import {Stage, Layer, Group, Rect, Arrow, Text} from 'react-konva';
    
    function AnnotatedVerticalArrow({x, y0, y1, text})
    {
      const textRef = React.useRef();
      const [calculatedTextSize, setCalculatedTextSize] =
        React.useState({width: 0, height: 0});
    
      React.useLayoutEffect(() => {
        if (textRef.current) {
          setCalculatedTextSize({
            width: textRef.current.measureSize(text).width,
            height: textRef.current.measureSize(text).height
          }); 
        }   
      }, [text]);  // "text" must be inside the dependency array
                   // otherwise repeated changes to its size will be ignored
    
      return (
        <Group>
          <Arrow
            points={[x, y0, x, y1]}
            pointerAtBeginning={true}
            pointerAtEnding={true}
            fill="black"
            stroke="black"
          />
          <Text
            ref={textRef}
            x={x - calculatedTextSize.width * 1.1}
            y={(y1 - y0) / 2 - calculatedTextSize.height / 2}
            text={text}
            fontSize={12}
          />  
        </Group>
      )
    }
    

    It remains working even if fontSize has been changed, since textRef is always a reference to the Text component that we've created in the rendering code path.