block by micahstubbs d1854e278cad0f9b4deef0815320d55f

Voronoi Scatterplot with diagram.find()

Full Screen

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]


2019.10.15

index.js

async function draw() {
  ////////////////////////////////////////////////////////////
  //////////////////////// Config ////////////////////////////
  ////////////////////////////////////////////////////////////

  const xVariable = 'GDP_perCapita'
  const yVariable = 'lifeExpectancy'
  const sizeVariable = 'GDP'
  const colorVariable = 'Region'
  const idVariable = 'CountryCode'
  const labelVariable = 'Country'

  const yAxisLabel = 'Life expectancy'
  const xAxisLabel = 'GDP per capita [US $] - Note the logarithmic scale'
  const legendName = 'GDP (Billion $)'
  const pageTitle = 'Scatterplot with Voronoi'
  const title = 'Life expectancy versus GDP per Capita'
  const subtitle = "now with d3 v4.3.0's diagram.find() 🎉"

  const colorPalette = [
    '#EFB605',
    '#E58903',
    '#E01A25',
    '#C20049',
    '#991C71',
    '#66489F',
    '#2074A0',
    '#10A66E',
    '#7EB852'
  ]

  const customXDomain = [100, 2e5]

  ////////////////////////////////////////////////////////////
  /////////////////////// Load Data //////////////////////////
  ////////////////////////////////////////////////////////////

  const marks = await d3.json('./data.json')

  ////////////////////////////////////////////////////////////
  //////////////////// Setup the Page ////////////////////////
  ////////////////////////////////////////////////////////////

  d3.select('title').html(pageTitle)
  d3.select('#title').html(title)
  d3.select('#subtitle').html(subtitle)
  d3.select('#legendTitle').html(colorVariable.toUpperCase())

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

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

  const width = Math.min($('#chart').width(), 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('transform', `translate(${margin.left},${margin.top})`)

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

  const opacityCircles = 0.7

  const maxDistanceFromPoint = 50

  const domain = Array.from(new Set(marks.map(d => d[colorVariable]))).sort()
  console.log('domain', domain)

  // Set the color for each region
  const color = d3
    .scaleOrdinal()
    .range(colorPalette)
    .domain(domain)

  // Set the new x axis range
  const xScale = d3.scaleLog().range([0, width])

  if (customXDomain) {
    xScale.domain(customXDomain) // I prefer this exact scale over the true range and then using "nice"
  } else {
    xScale.domain(d3.extent(marks, d => d[xVariable])).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(marks, 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(marks, d => d[sizeVariable]))

  //////////////////////////////////////////////////////
  ///////////////// 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(xAxisLabel)

  // 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(yAxisLabel)

  ////////////////////////////////////////////////////////////
  ///// 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[xVariable]))
        .y(d => yScale(d[yVariable]))(marks)
      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)
      if (site) showTooltip(site.data)
      svg._tooltipped = site
    }
  })

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

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

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

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

    const // dimensions of the colored square
      rectSize = 15 // width of each row

    const // height of a row in the legend
      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
    )
  } // if !mobileScreen
  else {
    d3.select('#legend').style('display', 'none')
  }

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

  function bubbleLegend(wrapperVar, scale, sizes, titleName) {
    const legendCenter = 0
    const legendBottom = 50
    const legendLineLength = 25
    const textPadding = 5
    const numFormat = d3.format(',')

    function drawBubble(legendSize) {
      wrapperVar
        .append('circle')
        .attr('r', scale(legendSize))
        .attr('class', 'legendCircle')
        .attr('cx', legendCenter)
        .attr('cy', legendBottom - scale(legendSize))
    }

    function drawLine(legendSize) {
      wrapperVar
        .append('line')
        .attr('class', 'legendLine')
        .attr('x1', legendCenter)
        .attr('y1', legendBottom - 2 * scale(legendSize))
        .attr('x2', legendCenter + legendLineLength)
        .attr('y2', legendBottom - 2 * scale(legendSize))
    }

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

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

    sizes.forEach(size => {
      drawBubble(size)
      drawLine(size)
      drawText(size)
    })
  } // 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('.marks')
        .filter(d => d[colorVariable] != chosen)
        .transition()
        .style('opacity', opacity)
    }
  } // function selectLegend

  ///////////////////////////////////////////////////////////////////////////
  /////////////////// Hover functions of the circles ////////////////////////
  ///////////////////////////////////////////////////////////////////////////

  // Hide the tooltip when the mouse moves away
  function removeTooltip(d, i) {
    // Save the chosen circle (so not the voronoi)
    const element = d3.selectAll(`.marks.${d[idVariable]}`)

    // Fade out the bubble again
    element.style('opacity', opacityCircles)

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

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

  // Show the tooltip on the hovered over slice
  function showTooltip(d, i) {
    // Save the chosen circle (so not the voronoi)
    const element = d3.select(`.marks.${d[idVariable]}`)

    const el = element._groups[0]
    // Define and show the tooltip
    $(el).popover({
      placement: 'auto top',
      container: '#chart',
      trigger: 'manual',
      html: true,
      content() {
        return `<span style='font-size: 11px; text-align: center;'>${d[labelVariable]}</span>`
      }
    })
    $(el).popover('show')

    // 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
    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('.2s')(d[xVariable])}`)
      .transition()
      .duration(200)
      .style('opacity', 0.5)

    // horizontal line
    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('.1f')(d[yVariable]))
      .transition()
      .duration(200)
      .style('opacity', 0.5)
  } // function showTooltip
}

draw()

index.html

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

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

		<!-- jQuery -->
		<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
		<!-- Latest compiled and minified CSS -->
		<link
			rel="stylesheet"
			href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"
		/>
		<!-- Latest compiled and minified JavaScript -->
		<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>

		<!-- Open Sans & 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=" />
		<link href="styles.css" rel="stylesheet" />
	</head>
	<body>
		<div id="cont" class="container-fluid text-center">
			<div class="row scatter">
				<h5 style="color: #3B3B3B;" id="title"></h5>
				<h6 style="color: #A6A6A6;" id="subtitle"></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"
						id="legendTitle"
						style="font-size: 12px;"
					></div>
					<div id="legend"></div>
				</div>
			</div>
		</div>
		<script src="index.js"></script>
	</body>
</html>

styles.css

body {
  font-family: 'Open Sans', sans-serif;
  font-size: 12px;
  font-weight: 400;
  color: #525252;
  text-align: center;
}

.axis path,
.axis line {
  fill: none;
  stroke: #b3b3b3;
  shape-rendering: crispEdges;
}
.axis text {
  font-size: 10px;
  fill: #6b6b6b;
}

.marks {
  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%;
  }
}