block by micahstubbs 378c73b53ce447bc325e3007829b9a01

ES2015 Voronoi Scatterplot with bootstrap-native popover tooltip

Full Screen

this but ES2015

—— 8X ——–

love that bootstrap popover tooltip but regret that jQuery depedency? this is the iteration for you.

enter bootstrap-native, a Vanilla JavaScript replacement for bootstrap

this example uses a fork of bootstrap native with improved error handling

removeTooltip.js and showTooltip.js code ported back to ES5 with https://babeljs.io/repl/

a fork of @micahstubbs‘s block Voronoi Scatterplot with diagram.find()

—— 8X ——–

this example uses the diagram.find() convention introduced in d3 version 4.3.0

a fork of Philippe Rivière‘s block Nadieh Bremer’s Scatterplot with Voronoi - ported to d3.v4, and no SVG overlay

—— 8X ——–

This is a D3.v4 port by Philippe Rivière of Nadieh Bremer‘s block: Step 6 - Final - Voronoi (Distance Limited Tooltip) Scatterplot.

In addition, we use d3.voronoi.find(x,y,radius) to locate the point, instead of relying on a SVG overlay of clipped circles.

This gives:

1) lazy computation of the Voronoi 2) other objects are allowed capture the mouse before svg.

—— 8X ——–

Nadieh:

This scatterplot is part of the extension of my blog on Using a D3 Voronoi grid to improve a chart’s interactive experience. After writing that blog Franck Lebeau came with another version which uses large circles to define the tooltip region. I thought this was a great idea! But I made this variation on his code, because I felt that the extra code used in this example (versus the previous version 4) is more in line with the rest of the code.

The tooltip now reacts when you hover over an invisible large circular region around each circle.

You can find all of the steps here

forked from Fil‘s block: Step 6 - d3.v4 [UNLISTED]

script.js

////////////////////////////////////////////////////////////
//////////////////////// Set-up ////////////////////////////
////////////////////////////////////////////////////////////

//Quick fix for resizing some things for mobile-ish viewers
// vanilla JS window width and height
// https://gist.github.com/joshcarr/2f861bd37c3d0df40b30

const wV = window;
const dV = document;
const eV = dV.documentElement;
const gV = dV.getElementsByTagName('body')[0];
const xV = wV.innerWidth || eV.clientWidth || gV.clientWidth;
const yV = wV.innerHeight || eV.clientHeight || gV.clientHeight;

// Quick fix for resizing some things for mobile-ish viewers
const mobileScreen = (xV < 500);

//Scatterplot
const margin = {left: 60, top: 20, right: 20, bottom: 60};

const width = Math.min(document.getElementById('chart').offsetWidth, 840) - margin.left - margin.right;
const height = width*2/3;

const svg = d3.select("#chart").append("svg")
  .attr("width", (width + margin.left + margin.right))
  .attr("height", (height + margin.top + margin.bottom));

const wrapper = svg.append("g").attr("class", "scatterWrapper")
  .attr("transform", `translate(${margin.left},${margin.top})`);

let idVariable = "CountryCode";
// add a an id property to each entry in the countries data
// if an idVariable is not defined already  	
countries.forEach((d, i) => {
	if (typeof idVariable === 'undefined') {
  	countries[i].id = `${i}`;
	}
})
if (typeof idVariable === 'undefined') idVariable = 'id';

//////////////////////////////////////////////////////
///////////// Initialize Axes & Scales ///////////////
//////////////////////////////////////////////////////

const opacityCircles = 0.7;

const maxDistanceFromPoint = 50;

//Set the color for each region
const color = d3.scaleOrdinal()
	.range([
    "#EFB605",
    "#E58903",
    "#E01A25",
    "#C20049", 
    "#991C71", 
    "#66489F", 
    "#2074A0", 
    "#10A66E", 
    "#7EB852"
  ])
	.domain([
    "Africa | North & East",
    "Africa | South & West",
    "America | North & Central",
    "America | South", 
		"Asia | East & Central", 
    "Asia | South & West",
    "Europe | North & West",
    "Europe | South & East", 
    "Oceania"
  ]);

//Set the new x axis range
const xScale = d3.scaleLog()
	.range([0, width])
	.domain([100,2e5]); //I prefer this exact scale over the true range and then using "nice"
//.domain(d3.extent(countries, function(d) { return d.GDP_perCapita; }))
//.nice();
//Set new x-axis
const xAxis = d3.axisBottom()
	.ticks(2)
	.tickFormat(d => xScale.tickFormat((mobileScreen ? 4 : 8),d => d3.format('$.2s')(d))(d))	
	.scale(xScale);
//Append the x-axis
wrapper.append("g")
	.attr("class", "x axis")
	.attr("transform", `translate(${0},${height})`)
	.call(xAxis);

//Set the new y axis range
const yScale = d3.scaleLinear()
	.range([height,0])
	.domain(d3.extent(countries, d => d.lifeExpectancy))
	.nice();
const yAxis = d3.axisLeft()
	.ticks(6)  //Set rough # of ticks
	.scale(yScale);
//Append the y-axis
wrapper.append("g")
	.attr("class", "y axis")
	.attr("transform", `translate(${0},${0})`)
	.call(yAxis);

//Scale for the bubble size
const rScale = d3.scaleSqrt()
	.range([mobileScreen ? 1 : 2, mobileScreen ? 10 : 16])
	.domain(d3.extent(countries, d => d.GDP));

//////////////////////////////////////////////////////
///////////////// Initialize Labels //////////////////
//////////////////////////////////////////////////////

//Set up X axis label
wrapper.append("g")
	.append("text")
	.attr("class", "x title")
	.attr("text-anchor", "end")
	.style("font-size", `${mobileScreen ? 8 : 12}px`)
	.attr("transform", `translate(${width},${height - 10})`)
	.text("GDP per capita [US $] - Note the logarithmic scale");

//Set up y axis label
wrapper.append("g")
	.append("text")
	.attr("class", "y title")
	.attr("text-anchor", "end")
	.style("font-size", `${mobileScreen ? 8 : 12}px`)
	.attr("transform", "translate(18, 0) rotate(-90)")
	.text("Life expectancy");

////////////////////////////////////////////////////////////  
//////////////// Setup for the tooltip  ////////////////////
////////////////////////////////////////////////////////////

// initialize variable for popover tooltip
let popoverTooltip;

const tooltipVariables = [
  {
    name: 'Country',
    valueOnly: true
  }
];

const xVariable = 'GDP_perCapita';
const yVariable = 'lifeExpectancy';

// strip out any white space
const xSelector = xVariable.replace(/\s/g, '');
const ySelector = yVariable.replace(/\s/g, '');

const xDroplineTextFormat = ".0f";
const yDroplineTextFormat = ".0f";

////////////////////////////////////////////////////////////	
///// Capture mouse events and voronoi.find() the site /////
////////////////////////////////////////////////////////////

// Use the same variables of the data in the .x and .y as used in the cx and cy of the circle call
svg._tooltipped = svg.diagram = null;
svg.on('mousemove', function() {
  if (!svg.diagram) {
    console.log('computing the voronoi…');
    svg.diagram = d3.voronoi()
        .x(d => xScale(d.GDP_perCapita))
        .y(d => yScale(d.lifeExpectancy))
        (countries);
    console.log('…done.');
  }
  const p = d3.mouse(this);
  let site;
  p[0] -= margin.left;
  p[1] -= margin.top;
  // don't react if the mouse is close to one of the axis
  if (p[0] < 5 || p[1] < 5) {
    site = null;
  } else {
    site = svg.diagram.find(p[0], p[1], maxDistanceFromPoint);
  }
  if (site !== svg._tooltipped) {
    if (svg._tooltipped) {
        // removeTooltip(svg._tooltipped.data)
        const removeTooltipOptions = {
        idVariable,
        xVariable,
        yVariable,
        xSelector,
        ySelector,
        wrapper,
        height,
        width
      };
      removeTooltip(svg._tooltipped.data, undefined, removeTooltipOptions, popoverTooltip)
    }
    if (site) {
        // showTooltip(site.data);
        const showTooltipOptions = {
        idVariable,
        xVariable,
        yVariable,
        xSelector,
        ySelector,
        wrapper,
        height,
        width,
        tooltipVariables,
        xDroplineTextFormat,
        yDroplineTextFormat
      };
      // return the updated popoverTooltip
      popoverTooltip = showTooltip(site.data, undefined, showTooltipOptions, popoverTooltip);
    }
    svg._tooltipped = site;
  }
});

////////////////////////////////////////////////////////////	
/////////////////// Scatterplot Circles ////////////////////
////////////////////////////////////////////////////////////	

//Initiate a group element for the circles	
const circleGroup = wrapper.append("g")
	.attr("class", "circleWrapper");

//Place the country circles
circleGroup.selectAll("countries")
	.data(countries.sort((a, b) => b.GDP > a.GDP)) //Sort so the biggest circles are below
	.enter().append("circle")
		.attr("class", (d, i) => `countries marks ${d.CountryCode}`)
		.attr("cx", d => xScale(d.GDP_perCapita))
		.attr("cy", d => yScale(d.lifeExpectancy))
		.attr("r", d => rScale(d.GDP))
		.style("opacity", opacityCircles)
		.style("fill", d => color(d.Region));

///////////////////////////////////////////////////////////////////////////
///////////////////////// Create the Legend////////////////////////////////
///////////////////////////////////////////////////////////////////////////

if (!mobileScreen) {
  //Legend			
  const legendMargin = {left: 5, top: 10, right: 5, bottom: 10};

  const legendWidth = 145;
  const legendHeight = 270;

  const svgLegend = d3.select("#legend").append("svg")
		.attr("width", (legendWidth + legendMargin.left + legendMargin.right))
		.attr("height", (legendHeight + legendMargin.top + legendMargin.bottom));

  const legendWrapper = svgLegend.append("g").attr("class", "legendWrapper")
		.attr("transform", `translate(${legendMargin.left},${legendMargin.top})`);
  
  //dimensions of the colored square
  const rectSize = 15; //width of each row

  //height of a row in the legend
  const rowHeight = 20;
  const maxWidth = 144;

  //Create container per rect/text pair  
  const legend = legendWrapper.selectAll('.legendSquare')  	
		.data(color.range())                              
		.enter().append('g')   
		.attr('class', 'legendSquare') 
		.attr("transform", (d, i) => `translate(${0},${i * rowHeight})`)
		.style("cursor", "pointer")
		.on("mouseover", selectLegend(0.02))
		.on("mouseout", selectLegend(opacityCircles));

  //Non visible white rectangle behind square and text for better hover
  legend.append('rect')                                     
	  .attr('width', maxWidth) 
	  .attr('height', rowHeight) 			  		  
	  .style('fill', "white");
  //Append small squares to Legend
  legend.append('rect')                                     
	  .attr('width', rectSize) 
	  .attr('height', rectSize) 			  		  
	  .style('fill', d => d);
  //Append text to Legend
  legend.append('text')                                     
	  .attr('transform', `translate(${22},${rectSize/2})`)
	  .attr("class", "legendText")
	  .style("font-size", "10px")
	  .attr("dy", ".35em")		  
	  .text((d, i) => color.domain()[i]);

  //Create g element for bubble size legend
  const bubbleSizeLegend = legendWrapper.append("g")
		.attr("transform", `translate(${legendWidth/2 - 30},${color.domain().length*rowHeight + 20})`);
  //Draw the bubble size legend
  bubbleLegend(bubbleSizeLegend, rScale, legendSizes = [1e11,3e12,1e13], legendName = "GDP (Billion $)");
}//if !mobileScreen
else {
	d3.select("#legend").style("display","none");
}

//////////////////////////////////////////////////////
/////////////////// Bubble Legend ////////////////////
//////////////////////////////////////////////////////

function bubbleLegend(wrapperVar, scale, sizes, titleName) {
  const legendSize1 = sizes[0];
  const legendSize2 = sizes[1];
  const legendSize3 = sizes[2];
  const legendCenter = 0;
  const legendBottom = 50;
  const legendLineLength = 25;
  const textPadding = 5;
  const numFormat = d3.format(",");

  wrapperVar.append("text")
		.attr("class","legendTitle")
		.attr("transform", `translate(${legendCenter},${0})`)
		.attr("x", `${0}px`)
		.attr("y", `${0}px`)
		.attr("dy", "1em")
		.text(titleName);

  wrapperVar.append("circle")
    .attr('r', scale(legendSize1))
    .attr('class',"legendCircle")
    .attr('cx', legendCenter)
    .attr('cy', (legendBottom-scale(legendSize1)));
  wrapperVar.append("circle")
    .attr('r', scale(legendSize2))
    .attr('class',"legendCircle")
    .attr('cx', legendCenter)
    .attr('cy', (legendBottom-scale(legendSize2)));
  wrapperVar.append("circle")
    .attr('r', scale(legendSize3))
    .attr('class',"legendCircle")
    .attr('cx', legendCenter)
    .attr('cy', (legendBottom-scale(legendSize3)));

  wrapperVar.append("line")
    .attr('class',"legendLine")
    .attr('x1', legendCenter)
    .attr('y1', (legendBottom-2*scale(legendSize1)))
	  .attr('x2', (legendCenter + legendLineLength))
    .attr('y2', (legendBottom-2*scale(legendSize1)));
  wrapperVar.append("line")
    .attr('class',"legendLine")
    .attr('x1', legendCenter)
    .attr('y1', (legendBottom-2*scale(legendSize2)))
	  .attr('x2', (legendCenter + legendLineLength))
    .attr('y2', (legendBottom-2*scale(legendSize2)));
  wrapperVar.append("line")
    .attr('class',"legendLine")
    .attr('x1', legendCenter)
    .attr('y1', (legendBottom-2*scale(legendSize3)))
	  .attr('x2', (legendCenter + legendLineLength))
    .attr('y2', (legendBottom-2*scale(legendSize3)));

  wrapperVar.append("text")
    .attr('class',"legendText")
    .attr('x', (legendCenter + legendLineLength + textPadding))
    .attr('y', (legendBottom-2*scale(legendSize1)))
	  .attr('dy', '0.25em')
	  .text(`$ ${numFormat(Math.round(legendSize1/1e9))} B`);
  wrapperVar.append("text")
    .attr('class',"legendText")
    .attr('x', (legendCenter + legendLineLength + textPadding))
    .attr('y', (legendBottom-2*scale(legendSize2)))
	  .attr('dy', '0.25em')
	  .text(`$ ${numFormat(Math.round(legendSize2/1e9))} B`);
  wrapperVar.append("text")
    .attr('class',"legendText")
    .attr('x', (legendCenter + legendLineLength + textPadding))
    .attr('y', (legendBottom-2*scale(legendSize3)))
	  .attr('dy', '0.25em')
	  .text(`$ ${numFormat(Math.round(legendSize3/1e9))} B`);
}//bubbleLegend

///////////////////////////////////////////////////////////////////////////
//////////////////// Hover function for the legend ////////////////////////
///////////////////////////////////////////////////////////////////////////

//Decrease opacity of non selected circles when hovering in the legend	
function selectLegend(opacity) {
	return (d, i) => {
		const chosen = color.domain()[i];
			
		wrapper.selectAll(".countries")
			.filter(d => d.Region != chosen)
			.transition()
			.style("opacity", opacity);
	  };
}//function selectLegend

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
	<title>Scatterplot with Voronoi</title>

	<!-- D3.js -->	
  <script src="//d3js.org/d3.v4.js"></script>

	<!-- bootstrap-native -->
	<!-- use custom build to handle tooltip removal cleanly -->
	<script type="text/javascript" src="https://cdn.rawgit.com/micahstubbs/bootstrap.native/improve-popover-remove/dist/bootstrap-native.js"></script>
	
	<!-- babel standalone -->
	<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.10.3/babel.min.js'></script>

	<!-- Open Sans & CSS -->
	<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
	<link href='//fonts.googleapis.com/css?family=Open+Sans:700,400,300' rel='stylesheet' type='text/css'>
	<link rel="icon" href="data:;base64,iVBORw0KGgo=">
	  <style>
		body {
		  font-family: 'Open Sans', sans-serif;
		  font-size: 12px;
		  font-weight: 400;
		  color: #525252;
		  text-align: center;
		}

		html, body { 
			width:auto; 
			height:auto; 
		}
		
		.axis path,
		.axis line {
			fill: none;
			stroke: #B3B3B3;
			shape-rendering: crispEdges;
		}
		.axis text {
			font-size: 10px;
			fill: #6B6B6B;
		}

		.countries {
			pointer-events: none;
		}

		.guide {
			pointer-events: none;
			font-size: 14px;
			font-weight: 600;
		}
		
		.popover {
			pointer-events: none;
		}
		
		.legendCircle {
			stroke-width:1;
			stroke:#999;
			stroke-dasharray:2 2;
			fill:none;
		}

		.legendLine {
			stroke-width: 1;
			stroke: #D1D1D1;
			shape-rendering: crispEdges;
		}

		.legendTitle {
			fill: #1A1A1A;
			color: #1A1A1A;
			text-anchor: middle;
			font-size: 10px;
		}

		.legendText {
			fill: #949494;
			text-anchor: start;
			font-size: 9px;
		}
		
		@media (min-width: 500px) {
		  .col-sm-3, .col-sm-9 {
			float: left;
		  }
		  .col-sm-9 {
			width: 75%;
		  }
		  .col-sm-3 {
			width: 25%;
		  }
		}
	  </style>
  </head>
  <body>

	<div id="cont" class="container-fluid text-center">
		<div class="row scatter">
			<h5 style="color: #3B3B3B;">Life expectancy versus GDP per Capita</h5>
			<h6 style="color: #A6A6A6;">now with ES2015 🔮</h6>
			<div class="col-sm-9">
				<div id="chart"></div>
			</div>
			<div id = "legend" class="col-sm-3" style="padding-right: 0px; padding-left: 0px;">
				<div class="legendTitle" style="font-size: 12px;">REGION</div>
				<div id="legend"></div>
			</div>
		</div>
	</div>

	<script src="worldbank.js"></script>
	<script src="removeTooltip.js"></script>
	<script src="showTooltip.js"></script>
  <script src="script.js"></script>
	
  </body>
</html>

removeTooltip.js

function removeTooltip(d, i, options, popoverTooltip) {
  const idVariable = options.idVariable;
  const xSelector = options.xSelector;
  const ySelector = options.ySelector;
  const tip = options.tip;
  const wrapper = options.wrapper;
  const height = options.height;
  const width = options.width;
  // Save the circle element (so not the voronoi which is triggering the hover event)
  // in a variable by using the unique class of the voronoi (idVariable)
  let elementSelector;
  if (typeof d.datum !== 'undefined' && typeof d.datum[idVariable] !== 'undefined') {
    elementSelector = `.marks.${d.datum[idVariable]}`;
  } else {
    elementSelector = `.marks.${d[idVariable]}`;
  }

  let element;
  if (typeof d.datum !== 'undefined' && typeof d.datum[idVariable] !== 'undefined') {
    element = d3.selectAll(`.marks.${d.datum[idVariable]}`);
  } else {
    element = d3.selectAll(`.marks.${d[idVariable]}`);
  }

  // Fade out the bright circle again
  element.style('fill-opacity', 0.3);

  //Hide tooltip
  if (typeof popoverTooltip !== 'undefined') {
    popoverTooltip.close();
  }

  //Fade out guide lines, then remove them
  d3.selectAll(".guide").transition().duration(200).style("opacity", 0).remove();
} //function removeTooltip

showTooltip.js

function showTooltip(d, i, options, popoverTooltip) {
  // test the bootstrap.native import
  // console.log('bsn', bsn);
  // var Popover = bsn.__moduleExports.Popover;

  const idVariable = options.idVariable;
  const xVariable = options.xVariable;
  const yVariable = options.yVariable;
  const xSelector = options.xSelector;
  const ySelector = options.ySelector;
  const tip = options.tip;
  const wrapper = options.wrapper;
  const height = options.height;
  const width = options.width;
  const tooltipVariables = options.tooltipVariables;
  const xDroplineTextFormat = options.xDroplineTextFormat;
  const yDroplineTextFormat = options.yDroplineTextFormat;
  // Save the circle element (so not the voronoi which is triggering the hover event)
  // in a variable by using the unique class of the voronoi (idVariable)
  let elementSelector;
  if (typeof d.datum !== 'undefined' && typeof d.datum[idVariable] !== 'undefined') {
    elementSelector = `.marks.${d.datum[idVariable]}`;
  } else {
    elementSelector = `.marks.${d[idVariable]}`;
  }
  console.log('elementSelector from showTooltip', elementSelector);

  const element = d3.selectAll(elementSelector);

  const el = element._groups[0];

  function generateHTML() {
    // console.log('d from tooltip html function', d);
    let allRows = '';
    tooltipVariables.forEach(e => {
      let currentValue;
      let f;
      if (typeof d.datum !== 'undefined') {
        f = d.datum;
      } else {
        f = d;
      }
      // now parse based on the format
      if (typeof e.format !== 'undefined') {
        if (e.type === 'time') {
          // time formatting
          const inputValue = new Date(Number(f[e.name]));
          // TODO: handle case where date values are strings
          const currentFormat = d3.timeFormat(e.format);
          currentValue = currentFormat(inputValue);
        } else {
          // number formatting
          const _inputValue = Number(f[e.name]);
          const _currentFormat = d3.format(e.format);
          currentValue = _currentFormat(_inputValue);
        }
      } else {
        // no formatting
        currentValue = f[e.name];
      }

      let currentText;
      if (typeof e.valueOnly !== 'undefined') {
        currentText = `${currentValue}`;
      } else {
        currentText = `${e.name} ${currentValue}`;
      }
      const currentRow = `<span style='font-size: 11px; display: block; text-align: center;'>${currentText}</span>`;
      allRows = allRows.concat(currentRow);
    });
    const html = `<div class="popover" role="tooltip">\n<div class="arrow"></div>\n<div class="popover-content">${allRows}</div>\n</div>`;
    // console.log('html from template', html);
    return html;
  };

  // close any lingering tooltips from
  // previous interactions
  d3.selectAll('.popover').remove();

  //Fade out guide lines, then remove them
  d3.selectAll('.guide').transition().duration(200).style('opacity', 0).remove();

  //Define and show the tooltip
  popoverTooltip = new Popover(elementSelector, {
    trigger: 'hover',
    duration: 100,
    delay: 100,
    template: generateHTML()
  });

  // console.log('popoverTooltip', popoverTooltip);
  popoverTooltip.open();

  //Make chosen circle more visible
  element.style("opacity", 1);

  //Place and show tooltip
  const x = +element.attr("cx");
  const y = +element.attr("cy");
  const color = element.style("fill");

  //
  //Append lines to bubbles that will be used to show the precise data points
  //

  //vertical line to x-axis
  wrapper.append("line").attr("class", "guide").attr("x1", x).attr("x2", x).attr("y1", y).attr("y2", height + 20).style("stroke", color).style("opacity", 0).transition().duration(200).style("opacity", 0.5);
  //Value on the axis
  wrapper.append("text").attr("class", "guide").attr("x", x).attr("y", height + 38).style("fill", color).style("opacity", 0).style("text-anchor", "middle").text(d3.format(xDroplineTextFormat)(d[xVariable])).transition().duration(200).style("opacity", 0.5);

  //horizontal line to y-axis
  wrapper.append("line").attr("class", "guide").attr("x1", x).attr("x2", -20).attr("y1", y).attr("y2", y).style("stroke", color).style("opacity", 0).transition().duration(200).style("opacity", 0.5);
  //Value on the axis
  wrapper.append("text").attr("class", "guide").attr("x", -25).attr("y", y).attr("dy", "0.35em").style("fill", color).style("opacity", 0).style("text-anchor", "end").text(d3.format(yDroplineTextFormat)(d[yVariable])).transition().duration(200).style("opacity", 0.5);

  return popoverTooltip;
} // function showTooltip