Canovice
Canovice

Reputation: 10173

D3 tooltip not updating correctly in a React Component

I spent quite a while making this code snippet to highlight a very frustrating issue I've run into using d3 and react together, in particular with trying to dynamically update tooltips based on component state.

First, the code snippet, which I've tried my best to keep short and complete (for brevity, you can skip over the css. just note the radio button group, and the mouseover in the graph component):

class GraphComponent extends React.Component {

  constructor(props) { 
    super(props);
  }

  // Lifecycle Components
  drawGraph() {
    const { buttonName } = this.props;
    
    const myData = [
      {x:25, y:30, name:"tommy", age:"24"},
      {x:108, y:82, name:"joey", age:"21"},
      {x:92, y:107, name:"nicky", age:"23"},
      {x:185, y:50, name:"peter", age:"27"},
      {x:65, y:80, name:"mickie", age:"4"},
      {x:165, y:80, name:"gregie", age:"14"},
      {x:154, y:10, name:"tammie", age:"24"},
      {x:102, y:42, name:"benny", age:"29"}
    ]

    // var myD3tip = d3Tip()
    var myD3tip = d3.tip()
			.attr('class', 'd3-tip')
			.offset([-20,0])
			.html(d => `<p>D3 Tip: ${buttonName}: ${d[buttonName]}</p>`)

    console.log('buttonName outside mouseover', buttonName)
    const divToolTip = d3.select('div#myTooltip')
    const points = d3.select('#mySvg').select('g.points')
    points.call(myD3tip)
    points
      .selectAll('circle')
      .data(myData)
      .enter()
      .append('circle')
      .attr('cx', d => d.x)
      .attr('cy', d => d.y)
      .attr('r', 8)
      .attr('fill', 'blue')
      .on('mouseover', function(d) {
        myD3tip.show(d)
        console.log('buttonName in mouseover', buttonName)
        divToolTip
          .style('opacity', 1)
          .style('left', d3.mouse(this)[0] + 'px')
          .style('top', (d3.mouse(this)[1]) + 'px')
          .html(`<p>Div Tip: ${buttonName}: ${d[buttonName]}</p>`)
      })
      .on('mouseout', function(d) {
        myD3tip.hide(d)
        divToolTip
          .style('opacity', 0)
      })
  }
	componentDidUpdate() {
    this.drawGraph()
  }
  componentDidMount() {
    this.drawGraph()
  }
  
	render() {
 		return (
      <svg id="mySvg">
        <g className="points" />
      </svg>
		)
	}
}

class GraphContainer extends React.Component {

	constructor(props) { 
		super(props);
    this.state = {
      ageOrName: "age"
    }
    
    this.handleChange = this.handleChange.bind(this);
	}

  handleChange = (event) => {
    this.setState({ ageOrName: event.target.value })
  }
   
	render() {
    
    const { ageOrName } = this.state;
    
    const ageOrNameOptions = ["age", "name"];
    const ageOrNameButtons = 
          <form>
            <div>
              {ageOrNameOptions.map((d, i) => {
                return (
                  <label key={i+d}>
                    <input 
                      type={"radio"}
                      value={ageOrNameOptions[i]}
                      checked={ageOrName === ageOrNameOptions[i]}
                      onChange={this.handleChange}
                      />
                    <span>{ageOrNameOptions[i]}</span>
                  </label>
                )
              })}
            </div>
          </form>;
    
 		return (
			<div>
         {ageOrNameButtons}
        
         <GraphComponent
           buttonName={ageOrName}
         />
      </div>
		)
	}
}


ReactDOM.render(
  <GraphContainer />,
  document.getElementById('root')
);
/* Div ToolTip */
#myTooltip {
	opacity: 0;
	position: absolute;
	pointer-events: none;
	background-color: lightblue;

	line-height: 0.50;
	font-weight: bold;
	padding: 8px 8px;
	padding-bottom: 0px;
	border-radius: 10px;
	border: 2px solid #444;
	font-size: 0.9em;
}
/* ========== */



/* D3 ToolTip */
/* ========== */
.d3-tip {
  line-height: 0.50;
  font-weight: bold;
  padding: 8px 8px;
  padding-bottom: 0px;
  border-radius: 10px;
  border: 2px solid #444;
  font-size: 0.9em;
}

/* Creates a small triangle extender for the tooltip */
.d3-tip:after {
  box-sizing: border-box;
  display: inline;
  font-size: 10px;
  width: 100%;
  line-height: 1;
  color: #444;
  content: "\25BC";
  position: absolute;
  text-align: center;
}

/* Style northward tooltips differently */
.d3-tip.n:after {
  margin: -1px 0 0 0;
  top: 100%;
  left: 0;
}
/* ========== */
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.development.js"></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.8.0-alpha.1/d3-tip.js"></script>

<div id="root"> 
  <svg id="mySvg">
</div>
<div id="myTooltip"></div>

The overview is this:

I have 2 components, a container component and a graph component. The container component has ageOrName state, and a radio button with "age" and "name" that updates this state. The container component then passes this value into the graph component.

The graph component, receiving ageOrName as a prop, uses this prop to change the text in the tooltip. However, its not working. For absolute completeness, I've included 2! different tooltips:

I have two console.logs() to display the value of the ageOrName prop inside of the graph component, and I've realized something very concerning. The value of the prop is different depending on whether I am inside of the "on.mouseover" call. I have no idea why.

Getting this to work is important for an app of mine, and any help at all with this is greatly appreciated!

Edit: Will almost certainly bounty this question in 2 days (would like to now). Will award even if an answer is provided sooner.

Upvotes: 3

Views: 2584

Answers (1)

Shashank
Shashank

Reputation: 5670

Main reason for the {buttonName} still picking up age EVEN after choosing the name input is that the mouseover function is bound to the circles during the first drawGraph call and the next time it's called again, there occurs the d3 update logic which doesn't add a new mouseover event to the existing circles (based on the new {buttonName}.

I figured out 2 approaches to resolve this:

  1. Just get the current {buttonName} in the mouseover function itself. Here's how:

    .on('mouseover', function(d) {
       var { buttonName } = that.props;
       myD3tip.html(d => `<p>D3 Tip: ${buttonName}: ${d[buttonName]}</p>`).show(d);
    

class GraphComponent extends React.Component {

  constructor(props) { 
    super(props);
  }

  // Lifecycle Components
  drawGraph() {
    var that = this;

    const { buttonName } = this.props;
    
    const myData = [
      {x:25, y:30, name:"tommy", age:"24"},
      {x:108, y:82, name:"joey", age:"21"},
      {x:92, y:107, name:"nicky", age:"23"},
      {x:185, y:50, name:"peter", age:"27"},
      {x:65, y:80, name:"mickie", age:"4"},
      {x:165, y:80, name:"gregie", age:"14"},
      {x:154, y:10, name:"tammie", age:"24"},
      {x:102, y:42, name:"benny", age:"29"}
    ]

    // var myD3tip = d3Tip()
    var myD3tip = d3.tip()
			.attr('class', 'd3-tip')
			.offset([-20,0])
			.html(d => `<p>D3 Tip: ${buttonName}: ${d[buttonName]}</p>`)

    const divToolTip = d3.select('div#myTooltip')
    const points = d3.select('#mySvg').select('g.points')
    points.call(myD3tip)
    points
      .selectAll('circle')
      .data(myData)
      .enter()
      .append('circle')
      .attr('cx', d => d.x)
      .attr('cy', d => d.y)
      .attr('r', 8)
      .attr('fill', 'blue');
      
    d3.select('#mySvg').select('g.points').selectAll('circle') 
      .on('mouseover', function(d) {
      	var { buttonName } = that.props;
        myD3tip.html(d => `<p>D3 Tip: ${buttonName}: ${d[buttonName]}</p>`).show(d);
        divToolTip
          .style('opacity', 1)
          .style('left', d3.mouse(this)[0] + 'px')
          .style('top', (d3.mouse(this)[1]) + 'px')
          .html(`<p>Div Tip: ${buttonName}: ${d[buttonName]}</p>`)
      })
      .on('mouseout', function(d) {
        myD3tip.hide(d)
        divToolTip
          .style('opacity', 0)
      })
  }
	componentDidUpdate() {
    this.drawGraph()
  }
  componentDidMount() {
    this.drawGraph()
  }
  
	render() {
 		return (
      <svg id="mySvg">
        <g className="points" />
      </svg>
		)
	}
}

class GraphContainer extends React.Component {

	constructor(props) { 
		super(props);
    this.state = {
      ageOrName: "age"
    }
    
    this.handleChange = this.handleChange.bind(this);
	}

  handleChange = (event) => {
    this.setState({ ageOrName: event.target.value })
  }
   
	render() {
    
    const { ageOrName } = this.state;
    
    const ageOrNameOptions = ["age", "name"];
    const ageOrNameButtons = 
          <form>
            <div>
              {ageOrNameOptions.map((d, i) => {
                return (
                  <label key={i+d}>
                    <input 
                      type={"radio"}
                      value={ageOrNameOptions[i]}
                      checked={ageOrName === ageOrNameOptions[i]}
                      onChange={this.handleChange}
                      />
                    <span>{ageOrNameOptions[i]}</span>
                  </label>
                )
              })}
            </div>
          </form>;
    
 		return (
			<div>
         {ageOrNameButtons}
        
         <GraphComponent
           buttonName={ageOrName}
         />
      </div>
		)
	}
}


ReactDOM.render(
  <GraphContainer />,
  document.getElementById('root')
);
/* Div ToolTip */
#myTooltip {
	opacity: 0;
	position: absolute;
	pointer-events: none;
	background-color: lightblue;

	line-height: 0.50;
	font-weight: bold;
	padding: 8px 8px;
	padding-bottom: 0px;
	border-radius: 10px;
	border: 2px solid #444;
	font-size: 0.9em;
}
/* ========== */



/* D3 ToolTip */
/* ========== */
.d3-tip {
  line-height: 0.50;
  font-weight: bold;
  padding: 8px 8px;
  padding-bottom: 0px;
  border-radius: 10px;
  border: 2px solid #444;
  font-size: 0.9em;
}

/* Creates a small triangle extender for the tooltip */
.d3-tip:after {
  box-sizing: border-box;
  display: inline;
  font-size: 10px;
  width: 100%;
  line-height: 1;
  color: #444;
  content: "\25BC";
  position: absolute;
  text-align: center;
}

/* Style northward tooltips differently */
.d3-tip.n:after {
  margin: -1px 0 0 0;
  top: 100%;
  left: 0;
}
/* ========== */
<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>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.8.0-alpha.1/d3-tip.js"></script>

<div id="root"> 
  <svg id="mySvg"></svg>
</div>
<div id="myTooltip"></div>

  1. Bind the mouseover event during every drawGraph call: (something like this)

    points.select('circle')
      ....
      .attr('fill', 'blue');
    
    d3.select('#mySvg').select('g.points').selectAll('circle')
       .on('mouseover', function(d) {
       .....
    

class GraphComponent extends React.Component {

  constructor(props) { 
    super(props);
  }

  // Lifecycle Components
  drawGraph() {
    const { buttonName } = this.props;
    
    const myData = [
      {x:25, y:30, name:"tommy", age:"24"},
      {x:108, y:82, name:"joey", age:"21"},
      {x:92, y:107, name:"nicky", age:"23"},
      {x:185, y:50, name:"peter", age:"27"},
      {x:65, y:80, name:"mickie", age:"4"},
      {x:165, y:80, name:"gregie", age:"14"},
      {x:154, y:10, name:"tammie", age:"24"},
      {x:102, y:42, name:"benny", age:"29"}
    ]

    // var myD3tip = d3Tip()
    var myD3tip = d3.tip()
			.attr('class', 'd3-tip')
			.offset([-20,0])
      .html(d => `<p>D3 Tip: ${buttonName}: ${d[buttonName]}</p>`);

    const divToolTip = d3.select('div#myTooltip')
    const points = d3.select('#mySvg').select('g.points')
    points.call(myD3tip)
    points
      .selectAll('circle')
      .data(myData)
      .enter()
      .append('circle')
      .attr('cx', d => d.x)
      .attr('cy', d => d.y)
      .attr('r', 8)
      .attr('fill', 'blue');
      
    d3.select('#mySvg').select('g.points').selectAll('circle')
      .on('mouseover', function(d) {
        myD3tip.show(d)
        divToolTip
          .style('opacity', 1)
          .style('left', d3.mouse(this)[0] + 'px')
          .style('top', (d3.mouse(this)[1]) + 'px')
          .html(`<p>Div Tip: ${buttonName}: ${d[buttonName]}</p>`)
      })
      .on('mouseout', function(d) {
        myD3tip.hide(d)
        divToolTip
          .style('opacity', 0)
      })
  }
	componentDidUpdate() {
    this.drawGraph()
  }
  componentDidMount() {
    this.drawGraph()
  }
  
	render() {
 		return (
      <svg id="mySvg">
        <g className="points" />
      </svg>
		)
	}
}

class GraphContainer extends React.Component {

	constructor(props) { 
		super(props);
    this.state = {
      ageOrName: "age"
    }
    
    this.handleChange = this.handleChange.bind(this);
	}

  handleChange = (event) => {
    this.setState({ ageOrName: event.target.value })
  }
   
	render() {
    
    const { ageOrName } = this.state;
    
    const ageOrNameOptions = ["age", "name"];
    const ageOrNameButtons = 
          <form>
            <div>
              {ageOrNameOptions.map((d, i) => {
                return (
                  <label key={i+d}>
                    <input 
                      type={"radio"}
                      value={ageOrNameOptions[i]}
                      checked={ageOrName === ageOrNameOptions[i]}
                      onChange={this.handleChange}
                      />
                    <span>{ageOrNameOptions[i]}</span>
                  </label>
                )
              })}
            </div>
          </form>;
    
 		return (
			<div>
         {ageOrNameButtons}
        
         <GraphComponent
           buttonName={ageOrName}
         />
      </div>
		)
	}
}


ReactDOM.render(
  <GraphContainer />,
  document.getElementById('root')
);
/* Div ToolTip */
#myTooltip {
	opacity: 0;
	position: absolute;
	pointer-events: none;
	background-color: lightblue;

	line-height: 0.50;
	font-weight: bold;
	padding: 8px 8px;
	padding-bottom: 0px;
	border-radius: 10px;
	border: 2px solid #444;
	font-size: 0.9em;
}
/* ========== */



/* D3 ToolTip */
/* ========== */
.d3-tip {
  line-height: 0.50;
  font-weight: bold;
  padding: 8px 8px;
  padding-bottom: 0px;
  border-radius: 10px;
  border: 2px solid #444;
  font-size: 0.9em;
}

/* Creates a small triangle extender for the tooltip */
.d3-tip:after {
  box-sizing: border-box;
  display: inline;
  font-size: 10px;
  width: 100%;
  line-height: 1;
  color: #444;
  content: "\25BC";
  position: absolute;
  text-align: center;
}

/* Style northward tooltips differently */
.d3-tip.n:after {
  margin: -1px 0 0 0;
  top: 100%;
  left: 0;
}
/* ========== */
<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>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.8.0-alpha.1/d3-tip.js"></script>

<div id="root"> 
  <svg id="mySvg"></svg>
</div>
<div id="myTooltip"></div>

(I'd personally choose the first approach)

Hope this helps.

Upvotes: 4

Related Questions