block by micahstubbs 0202be34ae1182b839fe2712307ea702

distance-limited Voronoi Interaction

Full Screen

an ES2015 d3v4 fork of the bl.ock distance-limited Voronoi Interaction III from Kcnarf

this iteration offers a few improvements and developer comforts:

in a future iteration, I’d like to:


Original README.md


This block is based on Step 3 - Voronoi Scatterplot - Tooltip attached to circle from @NadiehBremer

The Voronoi technics (used to improve interactive experience) is something I like. But I’m quite confused when the mouse is far away from points/subjectsOfMatter. In the original example, this situation arises in the viz’s top-left and bottom-right corners.

This block attempts to overcome this issue by:

For the sake of illustration, interactive areas appear in (very) light blue. Interactive zones would not be rendered in the final viz.

The implementation in this block uses a plugin I made (see d3-distanceLimitedVoronoi Github project) that computes the adequate interactive area around each point. The adequate path is: voronoï-cell INTERSECT max-distance-from-point. Others ways could be:

This third implementation of distance-limited voronoi cell is simpler than the 2 others because:

Acknowledgments to:

script.js

//
// Set-up
//

// map variables to our dataset
const xVariable = 'GDP_perCapita';
const yVariable = 'lifeExpectancy';
const rVariable = 'GDP';
const idVariable = 'CountryCode';
const groupByVariable = 'Region';
const tooltipVariable = 'Country';

// set label text
const xLabel = 'GDP per capita [US $] - Note the logarithmic scale';
const yLabel = 'Life expectancy';

// vanilla JS window width and height
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: 30, top: 20, right: 20, bottom: 20 };
const chartWidth = document.getElementById('chart').offsetWidth; 
const width = Math.min(chartWidth, 800) - margin.left - margin.right;
const height = width * 2 / 3;
// const maxDistanceFromPoint = 50;

const svg = d3.select('svg')
  .attr('width', (width + margin.left + margin.right))
  .attr('height', (height + margin.top + margin.bottom));

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

//
// Initialize Axes & Scales
//

const opacityCircles = 0.7;

// Set the color for each region
const color = d3.scaleOrdinal()
  .range([
    '#EFB605',
    '#E58903',
    '#E01A25',
    '#C20049',
    '#991C71',
    '#66489F',
    '#2074A0',
    '#10A66E',
    '#7EB852'
  ]);
 
d3.json('data.json', (error, data) => {
	// get values for color domain from the data
	const uniqueGroupByVariables = d3.set(data, d => d[groupByVariable])
		.values()
		.sort((a, b) => a > b); // ascending alphabetical sort

	// set the domain of the color scale
	// we use this later for the legend
	color.domain(uniqueGroupByVariables);

	// 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(data, function(d) { return d[xVariable]; }))
	  // .nice();

	// Set new x-axis
	const xAxis = d3.axisBottom()
	  .ticks(10)
	  .tickFormat(d => // Difficult function to create better ticks
	    xScale.tickFormat((mobileScreen ? 4 : 8), e => {
	      const prefix = d3.format(",.0s");
	      return `$${prefix(e)}`;
	    })(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(data, d => d[yVariable]))
	  .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(data, d => d[rVariable]));

	//
	// Scatterplot Circles
	//

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

	// Place the country circles
	circleGroup.selectAll('marks')
	  .data(data.sort((a, b) => b[rVariable] > a[rVariable])) // Sort so the biggest circles are below
	  .enter().append('circle')
	    .attr('class', (d) => `marks ${d[idVariable]}`)
	    .style('opacity', opacityCircles)
	    .style('fill', d => color(d[groupByVariable]))
	    .attr('cx', d => xScale(d[xVariable]))
	    .attr('cy', d => yScale(d[yVariable]))
	    .attr('r', d => rScale(d[rVariable]));

	//
	// Tooltips
	//

	const tip = d3.tip()
		.attr('class', 'd3-tip')
		.html(d => {
			return `<div style='background-color: white; padding: 5px; border-radius: 6px;
				border-style: solid; border-color: #D1D1D1; border-width: 1px;'>
				<span style='font-size: 11px; text-align: center;'>${d.datum[tooltipVariable]}</span>
				</div>`
		});

	svg.call(tip);

	//
	// distance-limited Voronoi
	//

	/*
	  Initiate the voronoi function
	  Use the same variables of the data in the .x and .y as used
	  in the cx and cy of the circle call
	  The clip extent will make the boundaries end nicely along
	  the chart area instead of splitting up the entire SVG
	  (if you do not do this it would mean that you already see
	  a tooltip when your mouse is still in the axis area, which
	  is confusing)
	*/

	const xAccessor = d => xScale(d[xVariable]);
	const yAccessor = d => yScale(d[yVariable]);

	const limitedVoronoi = d3.distanceLimitedVoronoi()
	  .x(xAccessor)
	  .y(yAccessor)
	  .limit(50)
	  .extent([[0, 0], [width, height]]);

	const limitedVoronoiCells = limitedVoronoi(data);

	// Initiate a group element to place the voronoi diagram in
	const limitedVoronoiGroup = wrapper.append('g')
	  .attr('class', 'voronoiWrapper');

	// Create the distance-limited Voronoi diagram
	limitedVoronoiGroup.selectAll('path')
	  .data(limitedVoronoiCells) // Use vononoi() with your dataset inside
	  .enter().append('path')
	    // .attr("d", function(d, i) { return "M" + d.join("L") + "Z"; })
	    .attr('d', (d, i) => d.path)
	    // Give each cell a unique class where the unique part corresponds to the circle classes
	    .attr('class', d => `voronoi ${d.datum[idVariable]}`)
	    .style('stroke', 'lightblue') // I use this to look at how the cells are dispersed as a check
	    .style('fill', 'none')
	    .style('pointer-events', 'all')
	    .on('mouseover', tip.show)
	    .on('mouseout', tip.hide);

	//
	// Initialize Labels
	//

	const xlabelText = xLabel || xVariable;
	const yLabelText = yLabel || yVariable;

	// 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(xlabelText);

	// 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(yLabelText);

	//
	// Create the Legend
	//

	if (!mobileScreen) {
	  // Legend
	  const legendMargin = { left: 5, top: 10, right: 5, bottom: 10 };
	  const legendWidth = 160;
	  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 = 16;

	  // height of a row in the legend
	  const rowHeight = 22;

	  // width of each row
	  // const maxWidth = 125

	  // 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})`);

	  // 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(${25},${rectSize / 2})`)
	    .attr('class', 'legendText')
	    .style('font-size', '11px')
	    .attr('dy', '.35em')
	    .text((d, i) => color.domain()[i]);

	// if !mobileScreen
	} else {
	  d3.select('#legend').style('display', 'none');
	}
})

index.html

<!DOCTYPE html>
<html>
<head>
<meta http-equiv='Content-Type' content='text/html;charset=utf-8'/>
<title>Scatterplot with Distance-Limited Voronoi</title>
<script src='https://d3js.org/d3.v4.min.js'></script>
<script src='https://rawgit.com/Kcnarf/d3-distanceLimitedVoronoi/d3v4/distance-limited-voronoi.js'></script>
<script src='d3-tip.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.10.3/babel.min.js'></script>
<link href='//fonts.googleapis.com/css?family=Open+Sans:700,400,300' rel='stylesheet' type='text/css'>
<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;
	}
	.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;'>Distance-limited Voronoi - tooltip shown only if mouse is close enougth</h6>
			<div class='col-sm-9'>
				<div id='chart'>
          <svg></svg>
        </div>
			</div>
			<div id = 'legend' class='col-sm-3'>
				<div class='legendTitle' style='font-size: 12px;'>REGION</div>
				<div id='legend'></div>
			</div>
		</div>
	</div>
  <script src='script.js' lang='babel' type='text/babel'></script>
</body>
</html>

d3-tip.js

// d3.tip
// Copyright (c) 2013 Justin Palmer
// ES6 / D3 v4 Adaption Copyright (c) 2016 Constantin Gavrilete
// Removal of ES6 for D3 v4 Adaption Copyright (c) 2016 David Gotz
//
// Tooltips for d3.js SVG visualizations

d3.functor = function functor(v) {
  return typeof v === "function" ? v : function() {
    return v;
  };
};

d3.tip = function() {

  var direction = d3_tip_direction,
      offset    = d3_tip_offset,
      html      = d3_tip_html,
      node      = initNode(),
      svg       = null,
      point     = null,
      target    = null

  function tip(vis) {
    svg = getSVGNode(vis)
    point = svg.createSVGPoint()
    document.body.appendChild(node)
  }

  // Public - show the tooltip on the screen
  //
  // Returns a tip
  tip.show = function() {
    var args = Array.prototype.slice.call(arguments)
    if(args[args.length - 1] instanceof SVGElement) target = args.pop()

    var content = html.apply(this, args),
        poffset = offset.apply(this, args),
        dir     = direction.apply(this, args),
        nodel   = getNodeEl(),
        i       = directions.length,
        coords,
        scrollTop  = document.documentElement.scrollTop || document.body.scrollTop,
        scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft

    nodel.html(content)
      .style('position', 'absolute')
      .style('opacity', 1)
      // comment this out to avoid flickering tooltips
      // .style('pointer-events', 'all')

    while(i--) nodel.classed(directions[i], false)
    coords = direction_callbacks[dir].apply(this)
    nodel.classed(dir, true)
      .style('top', (coords.top +  poffset[0]) + scrollTop + 'px')
      .style('left', (coords.left + poffset[1]) + scrollLeft + 'px')

    return tip
  }

  // Public - hide the tooltip
  //
  // Returns a tip
  tip.hide = function() {
    var nodel = getNodeEl()
    nodel
      .style('opacity', 0)
      .style('pointer-events', 'none')
    return tip
  }

  // Public: Proxy attr calls to the d3 tip container.  Sets or gets attribute value.
  //
  // n - name of the attribute
  // v - value of the attribute
  //
  // Returns tip or attribute value
  tip.attr = function(n, v) {
    if (arguments.length < 2 && typeof n === 'string') {
      return getNodeEl().attr(n)
    } else {
      var args =  Array.prototype.slice.call(arguments)
      d3.selection.prototype.attr.apply(getNodeEl(), args)
    }

    return tip
  }

  // Public: Proxy style calls to the d3 tip container.  Sets or gets a style value.
  //
  // n - name of the property
  // v - value of the property
  //
  // Returns tip or style property value
  tip.style = function(n, v) {
    // debugger;
    if (arguments.length < 2 && typeof n === 'string') {
      return getNodeEl().style(n)
    } else {
      var args = Array.prototype.slice.call(arguments);
      if (args.length === 1) {
        var styles = args[0];
        Object.keys(styles).forEach(function(key) {
          return d3.selection.prototype.style.apply(getNodeEl(), [key, styles[key]]);
        });
      }
    }

    return tip
  }

  // Public: Set or get the direction of the tooltip
  //
  // v - One of n(north), s(south), e(east), or w(west), nw(northwest),
  //     sw(southwest), ne(northeast) or se(southeast)
  //
  // Returns tip or direction
  tip.direction = function(v) {
    if (!arguments.length) return direction
    direction = v == null ? v : d3.functor(v)

    return tip
  }

  // Public: Sets or gets the offset of the tip
  //
  // v - Array of [x, y] offset
  //
  // Returns offset or
  tip.offset = function(v) {
    if (!arguments.length) return offset
    offset = v == null ? v : d3.functor(v)

    return tip
  }

  // Public: sets or gets the html value of the tooltip
  //
  // v - String value of the tip
  //
  // Returns html value or tip
  tip.html = function(v) {
    if (!arguments.length) return html
    html = v == null ? v : d3.functor(v)

    return tip
  }

  // Public: destroys the tooltip and removes it from the DOM
  //
  // Returns a tip
  tip.destroy = function() {
    if(node) {
      getNodeEl().remove();
      node = null;
    }
    return tip;
  }

  function d3_tip_direction() { return 'n' }
  function d3_tip_offset() { return [0, 0] }
  function d3_tip_html() { return ' ' }

  var direction_callbacks = {
    n:  direction_n,
    s:  direction_s,
    e:  direction_e,
    w:  direction_w,
    nw: direction_nw,
    ne: direction_ne,
    sw: direction_sw,
    se: direction_se
  };

  var directions = Object.keys(direction_callbacks);

  function direction_n() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.n.y - node.offsetHeight,
      left: bbox.n.x - node.offsetWidth / 2
    }
  }

  function direction_s() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.s.y,
      left: bbox.s.x - node.offsetWidth / 2
    }
  }

  function direction_e() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.e.y - node.offsetHeight / 2,
      left: bbox.e.x
    }
  }

  function direction_w() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.w.y - node.offsetHeight / 2,
      left: bbox.w.x - node.offsetWidth
    }
  }

  function direction_nw() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.nw.y - node.offsetHeight,
      left: bbox.nw.x - node.offsetWidth
    }
  }

  function direction_ne() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.ne.y - node.offsetHeight,
      left: bbox.ne.x
    }
  }

  function direction_sw() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.sw.y,
      left: bbox.sw.x - node.offsetWidth
    }
  }

  function direction_se() {
    var bbox = getScreenBBox()
    return {
      top:  bbox.se.y,
      left: bbox.e.x
    }
  }

  function initNode() {
    var node = d3.select(document.createElement('div'))
    node
      .style('position', 'absolute')
      .style('top', 0)
      .style('opacity', 0)
      .style('pointer-events', 'none')
      .style('box-sizing', 'border-box')

    return node.node()
  }

  function getSVGNode(el) {
    el = el.node()
    if(el.tagName.toLowerCase() === 'svg')
      return el

    return el.ownerSVGElement
  }

  function getNodeEl() {
    if(node === null) {
      node = initNode();
      // re-add node to DOM
      document.body.appendChild(node);
    };
    return d3.select(node);
  }

  // Private - gets the screen coordinates of a shape
  //
  // Given a shape on the screen, will return an SVGPoint for the directions
  // n(north), s(south), e(east), w(west), ne(northeast), se(southeast), nw(northwest),
  // sw(southwest).
  //
  //    +-+-+
  //    |   |
  //    +   +
  //    |   |
  //    +-+-+
  //
  // Returns an Object {n, s, e, w, nw, sw, ne, se}
  function getScreenBBox() {
    var targetel   = target || d3.event.target;

    while ('undefined' === typeof targetel.getScreenCTM && 'undefined' === targetel.parentNode) {
        targetel = targetel.parentNode;
    }

    var bbox       = {},
        matrix     = targetel.getScreenCTM(),
        tbbox      = targetel.getBBox(),
        width      = tbbox.width,
        height     = tbbox.height,
        x          = tbbox.x,
        y          = tbbox.y

    point.x = x
    point.y = y
    bbox.nw = point.matrixTransform(matrix)
    point.x += width
    bbox.ne = point.matrixTransform(matrix)
    point.y += height
    bbox.se = point.matrixTransform(matrix)
    point.x -= width
    bbox.sw = point.matrixTransform(matrix)
    point.y -= height / 2
    bbox.w  = point.matrixTransform(matrix)
    point.x += width
    bbox.e = point.matrixTransform(matrix)
    point.x -= width / 2
    point.y -= height / 2
    bbox.n = point.matrixTransform(matrix)
    point.y += height
    bbox.s = point.matrixTransform(matrix)

    return bbox
  }

  return tip
};

v3.js

//
// Set-up
//

// Quick fix for resizing some things for mobile-ish viewers
const mobileScreen = ($(window).innerWidth() < 500);

// Scatterplot
const margin = { left: 30, top: 20, right: 20, bottom: 20 };
const width = Math.min($('#chart').width(), 800) - margin.left - margin.right;
const height = width * 2 / 3;
// const maxDistanceFromPoint = 50;

const svg = d3.select('svg')
  .attr('width', (width + margin.left + margin.right))
  .attr('height', (height + margin.top + margin.bottom));

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

//
// Initialize Axes & Scales
//

const opacityCircles = 0.7;

// Set the color for each region
const color = d3.scale.ordinal()
  .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'
  ]);

d3.json('data.json', (error, data) => {
		// Set the new x axis range
	const xScale = d3.scale.log()
	  .range([0, width])
	  .domain([100, 2e5]);
	  // I prefer this exact scale over the true range and then using "nice"
	  // .domain(d3.extent(data, function(d) { return d.GDP_perCapita; }))
	  // .nice();

	// Set new x-axis
	const xAxis = d3.svg.axis()
	  .orient('bottom')
	  .ticks(2)
	  .tickFormat(d => // Difficult function to create better ticks
	    xScale.tickFormat((mobileScreen ? 4 : 8), e => {
	      const prefix = d3.formatPrefix(e);
	      return `$${prefix.scale(e)}${prefix.symbol}`;
	    })(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.scale.linear()
	  .range([height, 0])
	  .domain(d3.extent(data, d => d.lifeExpectancy))
	  .nice();

	const yAxis = d3.svg.axis()
	  .orient('left')
	  .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.scale.sqrt()
	      .range([
	        mobileScreen ? 1 : 2,
	        mobileScreen ? 10 : 16
	      ])
	      .domain(d3.extent(data, d => d.GDP));

	//
	// 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(data.sort((a, b) => b.GDP > a.GDP)) // Sort so the biggest circles are below
	  .enter().append('circle')
	    .attr('class', (d) => `countries ${d.CountryCode}`)
	    .style('opacity', opacityCircles)
	    .style('fill', d => color(d.Region))
	    .attr('cx', d => xScale(d.GDP_perCapita))
	    .attr('cy', d => yScale(d.lifeExpectancy))
	    .attr('r', d => rScale(d.GDP));

	//
	// distance-limited Voronoi
	//

	/*
	  Initiate the voronoi function
	  Use the same variables of the data in the .x and .y as used
	  in the cx and cy of the circle call
	  The clip extent will make the boundaries end nicely along
	  the chart area instead of splitting up the entire SVG
	  (if you do not do this it would mean that you already see
	  a tooltip when your mouse is still in the axis area, which
	  is confusing)
	*/

	const xAccessor = d => xScale(d.GDP_perCapita);
	const yAccessor = d => yScale(d.lifeExpectancy);

	const limitedVoronoi = d3.distanceLimitedVoronoi()
	  .x(xAccessor)
	  .y(yAccessor)
	  .limit(50)
	  .clipExtent([[0, 0], [width, height]]);

	const limitedVoronoiCells = limitedVoronoi(data);

	// Initiate a group element to place the voronoi diagram in
	const limitedVoronoiGroup = wrapper.append('g')
	  .attr('class', 'voronoiWrapper');

	// Hide the tooltip when the mouse moves away
	function removeTooltip(d) {
	  // 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 (CountryCode)
	  const element = d3.selectAll(`.countries.${d.CountryCode}`);

	  // Hide the tooltip
	  $('.popover').each(function () {
	    $(this).remove();
	  });

	  // Fade out the bright circle again
	  element.style('opacity', opacityCircles);
	}// function removeTooltip

	// Show the tooltip on the hovered over circle
	function showTooltip(d) {
	  // 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 (CountryCode)
	  const element = d3.selectAll(`.countries.${d.CountryCode}`);

	  // skip tooltip creation if already defined
	  const existingTooltip = $('.popover');
	  if (existingTooltip !== null
	      && existingTooltip.length > 0
	      && existingTooltip.text() === d.Country) {
	    return;
	  }

	  // Define and show the tooltip using bootstrap popover
	  // But you can use whatever you prefer
	  $(element).popover({
	    placement: 'auto top', // place the tooltip above the item
	    container: '#chart', // the name (class or id) of the container
	    trigger: 'manual',
	    html: true,
	    content() { // the html content to show inside the tooltip
	      return `<span style='font-size: 11px; text-align: center;'>${d.Country}</span>`;
	    }
	  });
	  $(element).popover('show');

	  // Make chosen circle more visible
	  element.style('opacity', 1);
	}// function showTooltip

	// Create the distance-limited Voronoi diagram
	limitedVoronoiGroup.selectAll('path')
	  .data(limitedVoronoiCells) // Use vononoi() with your dataset inside
	  .enter().append('path')
	    // .attr("d", function(d, i) { return "M" + d.join("L") + "Z"; })
	    .attr('d', (d, i) => d.path)
	    .datum((d, i) => d.point)
	    // Give each cell a unique class where the unique part corresponds to the circle classes
	    .attr('class', (d, i) => `voronoi ${d.CountryCode}`)
	    .style('stroke', 'lightblue') // I use this to look at how the cells are dispersed as a check
	    .style('fill', 'none')
	    .style('pointer-events', 'all')
	    // .on('mouseenter', showTooltip)
	    // .on('mouseout', removeTooltip);

	//
	// 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');

	//
	// Create the Legend
	//

	if (!mobileScreen) {
	  // Legend
	  const legendMargin = { left: 5, top: 10, right: 5, bottom: 10 };
	  const legendWidth = 160;
	  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 = 16;

	  // height of a row in the legend
	  const rowHeight = 22;

	  // width of each row
	  // const maxWidth = 125

	  // 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})`);

	  // 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(${25},${rectSize / 2})`)
	    .attr('class', 'legendText')
	    .style('font-size', '11px')
	    .attr('dy', '.35em')
	    .text((d, i) => color.domain()[i]);

	// if !mobileScreen
	} else {
	  d3.select('#legend').style('display', 'none');
	}

	// iFrame handler
	// iFrame handler
	const pymChild = new pym.Child();
	pymChild.sendHeight();
	setTimeout(() => { pymChild.sendHeight(); }, 5000);
})