Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Calculating SVG bounding boxes with React?

I am writing a visualisation application with React generating SVG. One of the parts I need is a label - that is, text, surrounded by an enclosing box, with variable text, possibly rotated and styled.

So I have a component for the NodeLabel, currently with fixed dimensions:

render() {
        return <g>
            <rect className="label" x={this.props.x} y={this.props.y-10} width={20} height={40}></rect>
            <text className="labelText" x={this.props.x} y={this.props.y}>{this.props.children}</text>
        </g>
    }

And I've found some info about doing this in the DOM, here: Rectangle border around SVG text

But I don't quite see how to translate this into a React component - inside the render() method, there's no DOM elements to look at. Can I just use document.createElement() instead and expect an SVG element's dimensions to behave properly (and honour CSS)? Also, is there a way to avoid having essentially two copies of the creation code, one in JSX and one just before that to figure out dimensions? (like, for example, evaluating a snippet of JSX to DOM elements for this temporary off-screen copy)

Update: Jan 2018 and I'm back at this again :-) The actual application is an open source network diagramming tool, currently using GD and PHP, but moving to JS, React and SVG, I hope.

The bandwidth labels here are what I'm trying to reproduce, although the node labels use the same function in the current non-SVG version.

The bandwidth labels here are what I'm trying to reproduce, although the node labels use the same function in the current non-SVG version.

Here is my new minimal example:

// MyLabel should be centred at x,y, rotated by angle, 
// and have a bounding box around it, 2px from the text.
class MyLabel extends React.Component {
  render() {
    const label = <text x={this.props.x} y={this.props.y} textAnchor="middle" alignmentBaseline="central">{this.props.children}</text>;
        
    // label isn't a DOM element, so you can't call label.getBoundingClientRect() or getBBox()

    // (Magic happens here to find bbox of label..)        
    // make up a static one for now
    let bb = {x: this.props.x-20, y: this.props.y-6, width: 40, height: 12};
    
    // add margin
    const margin = 2;
    bb.width += margin * 2;
    bb.height += margin * 2;
    bb.x -= margin;
    bb.y -= margin;
    
    // rect uses bbox to decide its size and position
    const outline = <rect x={bb.x} y={bb.y} width={bb.width} height={bb.height} className="labeloutline"></rect>;
    
    const rot = `rotate(${this.props.angle} ${this.props.x} ${this.props.y})`;
    // build the final label (plus an x,y spot for now)
    return <g transform={rot}>{outline}{label}<circle cx={this.props.x} cy={this.props.y} r="2" fill="red" /></g>;
  }
}

class Application extends React.Component {
  render() {
    return <svg width={300} height={300}>
      <MyLabel x={100} y={100} angle={0}>Dalmation</MyLabel>
      <MyLabel x={200} y={100} angle={45}>Cocker Spaniel</MyLabel>
      <MyLabel x={100} y={200} angle={145}>Pug</MyLabel>
      <MyLabel x={200} y={200} angle={315}>Pomeranian</MyLabel>
    </svg>;
  }
}

/*
 * Render the above component into the div#app
 */
ReactDOM.render(<Application />, document.getElementById('app'));
body { background: gray; }
svg {background: lightgray;}
.labeloutline { fill: white; stroke: black;}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>

<div id="app"></div>
like image 336
AnotherHowie Avatar asked Oct 17 '22 10:10

AnotherHowie


1 Answers

You either precompute/measure your typeface geometry and get a reasonable estimate of the text dimensions based on the input string (this is the simplest solution, but will obviously break if the typeface changes), or perform a two-stage rendering:

that is, you obtain the dom element via the ref, fetch the box on mount and finally re-render by updating state, something like:

class MyLabel extends React.Component {
  constructor(props){
    super(props);
    this.state = {text_extents:null};
  }
  componentDidMount() {
   const box = this.text.getBBox();

   this.setState({text_extents:[box.width,box.height]});
  }
 render() {
   const margin = 2;
   const extents = this.state.text_extents;
   const label = <text ref={(t) => { this.text = t; }} textAnchor="middle" dy={extents?(extents[1]/4):0} >{this.props.children}</text>;
   const outline = extents ?
         <rect x={-extents[0]/2-margin} y={-extents[1]/2-margin} width={extents[0]+2*margin} height={extents[1]+2*margin} className="labeloutline"></rect>
         : null;

   return <g transform={`translate(${this.props.x},${this.props.y}) rotate(${this.props.angle})`}>{outline}{label}</g>;
 }
}

Note that, as per latest react docs, this should not incur in any user visible flickering:

componentDidMount(): Calling setState() in this method will trigger an extra rendering, but it will happen before the browser updates the screen. This guarantees that even though the render() will be called twice in this case, the user won’t see the intermediate state. Use this pattern with caution because it often causes performance issues. It can, however, be necessary for cases like modals and tooltips when you need to measure a DOM node before rendering something that depends on its size or position.

Finally, note that if the label string changes (via props or whatever) you'll need to update extents accordingly (via componentDidUpdate()).

like image 103
Massimiliano Janes Avatar answered Oct 30 '22 01:10

Massimiliano Janes