D3 Bubble Chart

In this exercise, you will build interactive graphics that animate changes over time. We’ll use the Gapminder Foundation dataset on global health and population measures, recreating a “bubble plot” made famous in Hans Rosling’s TED presentation.

Let’s load and preview our dataset, and then get going!

const gapminder = vega_datasets['gapminder.json']()

Task 1: Build the Base Plot

We’ll begin by creating a static scatter plot for a single year. For this phase, we will put into practice most of what we learned in Introduction to D3.

Here is a static image of the chart we want to create:

As preliminary steps, we’ll compute the minimum and maximum years in the data (using the d3.extent helper), and filter our dataset to the initial year:

const years = d3.extent(gapminder, d => d.year)
const dataInitial = gapminder.filter(d => d.year === years[0])

Next, let’s set up the basic dimensions of our chart. For the width, we will use the built-in width variable provided by Observable (though you should feel free to override this if you want to). We will also set our desired height, plus a margin object with space to accommodate x and y axes.

Our envisioned design is a scatter plot of countries (circles) that we:

For each of these attributes, we need a corresponding scale to map from data values to visual values. For the x and y axes we can use standard linear scales, ensure a zero baseline, and include margin offsets in the scale range to ensure appropriate sizing.

const x = d3.scaleLinear()
  .domain([0, d3.max(gapminder, d => d.fertility)])
  .range([margin.left, width - margin.right])
  .nice();
const y = d3.scaleLinear()
  .domain([0, d3.max(gapminder, d => d.life_expect)])
  .range([height - margin.bottom, margin.top])
  .nice();

For the color encoding, we will use a categorical color palette designed to help distinguish category (nominal) values. Here we use D3’s schemeTableau10, but feel free to experiment with other color palettes provided by d3-scale-chromatic or use your own array of custom color values.

const color = d3.scaleOrdinal()
  .domain(gapminder.map(d => d.cluster))
  .range(d3.schemeTableau10); // try other schemes, too!
d3.extent(gapminder, d => d.cluster)

For the size encoding we use scaleSqrt, a non-linear scale the returns values proportional to the square root of the input values. Why? We want our size encoding to vary the circular area (A) relative to the underlying data, but we will be explicitly assigning the circle radius ® values… and of course !

const size = d3.scaleSqrt()
  .domain(d3.extent(gapminder, d => d.pop))
  .range([4, 35]); // output radii range from 4 to 35 pixels

Using our scale definitions, we can generate our base plot. Fill in the attributes below to use proper scale encodings.

display((() => {
  // create the container SVG element
  const svg = d3.create('svg')
    .attr('width', width)
    .attr('height', height);

  // position and populate the x-axis
  svg.append('g')
    .attr('transform', `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(x));

  // position and populate the y-axis
  svg.append('g')
    .attr('transform', `translate(${margin.left}, 0)`)
    .call(d3.axisLeft(y));

  // add circle elements for each country
  // use scales to set fill color, x, y, and radius
  const countries = svg
    .selectAll('circle')
    .data(dataInitial)
    .join('circle')
      .attr('opacity', 0.75)
      .attr('fill', d => 'black' /* update code here */)
      .attr('cx', d => 0 /* update code here */)
      .attr('cy', d => 0 /* update code here */)
      .attr('r', d => 0 /* update code here */);

  // return the SVG DOM element for display
  return svg.node();
})());

The chart above is a good start, but not very interpretable. What do the axes represent? What countries do the circles correspond to? What year is it?

Let’s add labels to the chart and tooltips for the countries. To add axis titles and a year label, we can append additional text elements and style them. To add tooltips, we can append a <title> element to our country circles: the browser will then show the text as tooltips after a short hover delay. We should also sort the countries so that smaller circles are drawn last and not occluded by larger circles.

While we won’t concern ourselves here, note that using built-in browser tooltips can be limiting: they may take a while to display and do not support rich formatting. (For alternatives, see the Learn D3: Interaction notebook.)

As we wait for a tooltip, it would be nice for the chart to clearly indicate which country has been selected. For example, we could draw a stroke around the circle that the mouse pointer is currently visiting. To achieve this, we can add event listeners to update <circle> attributes upon mouseover and mouseout events.

Read the code below for our extended chart, with comment annotations.

display((() => {
  const svg = d3.create('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g')
    .attr('transform', `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(x))
    // Add x-axis title 'text' element.
    .append('text')
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .attr('x', width - margin.right)
      .attr('y', -10)
      .text('Fertility');

  svg.append('g')
    .attr('transform', `translate(${margin.left}, 0)`)
    .call(d3.axisLeft(y))
    // Add y-axis title 'text' element.
    .append('text')
      .attr('transform', `translate(20, ${margin.top}) rotate(-90)`)
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .text('Life Expectancy');

  // Add a background label for the current year.
  const yearLabel = svg.append('text')
    .attr('class', 'year')
    .attr('x', 40)
    .attr('y', height - margin.bottom - 20)
    .attr('fill', '#ccc')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-weight', 500)
    .attr('font-size', 80)
    .text(years[0]);

  const countries = svg
    .selectAll('circle')
    .data(dataInitial)
    .join('circle')
      .sort((a, b) => b.pop - a.pop) // <-- sort so smaller circles are drawn last
      .attr('class', 'country')
      .attr('opacity', 0.75)
      .attr('fill', d => color(d.cluster))
      .attr('cx', d => x(d.fertility))
      .attr('cy', d => y(d.life_expect))
      .attr('r', d => size(d.pop));

  // add a tooltip
  countries
    .append('title')
    .text(d => d.country);

  // Add mouse hover interactions, using D3 to update attributes directly.
  // In a stand-alone context, we could also use stylesheets with 'circle:hover'.
  countries
     // The 'on()' method registers an event listener function
    .on('mouseover', function() {
      // The 'this' variable refers to the underlying SVG element.
      // We can select it directly, then use D3 attribute setters.
      // (Note that 'this' is set when using "function() {}" definitions,
      //  but *not* when using arrow function "() => {}" definitions.)
      d3.select(this).attr('stroke', '#333').attr('stroke-width', 2);
    })
    .on('mouseout', function() {
      // Setting the stroke color to null removes it entirely.
      d3.select(this).attr('stroke', null);
    });

  return svg.node();
})());

Task 2: Filter by Year

Now it is time to give our chart some life! Before we move on to more advanced D3 capabilities, let’s use Observable’s reactive updates to interactively filter by year.

We can start by adding a dynamic query widget to selecting the current year. The built-in Inputs.range component provides a slider.

Let’s instantiate a slider for the 5-year increments in our dataset. Then we only need to wire up the yearFilter value to our chart definition.

Update the chart code below to use yearFilter to set the year label text and filter the dataset. Then drag the slider to explore the data!

const yearFilter = view(Inputs.range(years, { step: 5 }));
display((() => {
  const svg = d3.create('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g')
    .attr('transform', `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(x))
    .append('text')
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .attr('x', width - margin.right)
      .attr('y', -10)
      .text('Fertility');

  svg.append('g')
    .attr('transform', `translate(${margin.left}, 0)`)
    .call(d3.axisLeft(y))
    .append('text')
      .attr('transform', `translate(20, ${margin.top}) rotate(-90)`)
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .text('Life Expectancy');

  const yearLabel = svg.append('text')
    .attr('class', 'year')
    .attr('x', 40)
    .attr('y', height - margin.bottom - 20)
    .attr('fill', '#ccc')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-weight', 500)
    .attr('font-size', 80)
    .text(years[0]); // <-- Update to use yearFilter

  const countries = svg
    .selectAll('circle')
    .data(gapminder.filter(d => d.year === years[0])) // <-- Update to use yearFilter
    .join('circle')
      .sort((a, b) => b.pop - a.pop)
      .attr('class', 'country')
      .attr('opacity', 0.75)
      .attr('fill', d => color(d.cluster))
      .attr('cx', d => x(d.fertility))
      .attr('cy', d => y(d.life_expect))
      .attr('r', d => size(d.pop));

  countries
    .append('title')
    .text(d => d.country);

  countries
    .on('mouseover', function() {
      d3.select(this).attr('stroke', '#333').attr('stroke-width', 2);
    })
    .on('mouseout', function() {
      d3.select(this).attr('stroke', null);
    });

  return svg.node();
})());

Even if you ultimately plan to use other methods, you can leverage Observable’s reactive updates to quickly prototype interactive design ideas!`


Task 3: Animate Transitions between Years

Our interactive chart above is fun, but we can do better! First, using Observable’s reactive updates causes the entire cell (and thus the entire visualization) to be re-computed from scratch on every update. This is not very efficient, and can lead to poor performance with larger datasaets. Second, our updates are “jumpy”: we would prefer smooth animations between timesteps. 😎

To reuse our chart, we need to provide a mechanism for updating it. We can update our chart code by defining a function that updates the data for a given year and then animates the changes to the visualization. We can then export this update method alongside our chart, invoking it each time our slider updates.

The “key” 🔑 step here is to provide a key function as part of the input to the D3 selection data() method. The key function tells D3 how to match data to the DOM. When the input data changes, how should the data values be joined with our SVG elements? Here we use the key function d => d.country to indicate a circle should bind to a new input data value if its country property matches the old input value. That way, when we filter across years and provide a different set of inputs, those inputs still get resolved to the correct SVG <circle> elements.

Play with the slider below to see our smooth animated transitions. Then read the code below; the comments describe the changes we have made.

const yearAnimate = view(Inputs.range(years, { step: 5 }));

Average Fertility & Life Expectancy by Country in .

const chartAnimate = (() => {
  const svg = d3.create('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g')
    .attr('transform', `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(x))
    .append('text')
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .attr('x', width - margin.right)
      .attr('y', -10)
      .text('Fertility');

  svg.append('g')
    .attr('transform', `translate(${margin.left}, 0)`)
    .call(d3.axisLeft(y))
    .append('text')
      .attr('transform', `translate(20, ${margin.top}) rotate(-90)`)
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .text('Life Expectancy');

  const yearLabel = svg.append('text')
    .attr('class', 'year')
    .attr('x', 40)
    .attr('y', height - margin.bottom - 20)
    .attr('fill', '#ccc')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-weight', 500)
    .attr('font-size', 80)
    .text(years[0]); // <-- simply use the minimum year, as updates occur elsewhere

  const countries = svg
    .selectAll('circle.country')
    // Bind to the filtered initial data, we will perform updates elsewhere
    // Provide a 🔑 key function for joining data to SVG elements
    .data(dataInitial, d => d.country) // <-- Add key function!!
    .join('circle')
      .sort((a, b) => b.pop - a.pop)
      .attr('class', 'country')
      .attr('opacity', 0.75)
      .attr('fill', d => color(d.cluster))
      .attr('cx', d => x(d.fertility))
      .attr('cy', d => y(d.life_expect))
      .attr('r', d => size(d.pop));

  countries
    .append('title')
    .text(d => d.country);

  countries
    .on('mouseover', function() {
      d3.select(this).attr('stroke', '#333').attr('stroke-width', 2);
    })
    .on('mouseout', function() {
      d3.select(this).attr('stroke', null);
    });

  // Update function: given a year value, update the chart.
  function setYear(year) {
    // Update the year label by simply setting it to the new value.
    yearLabel.text(year);

    // Update countries and animate the transition:
    // 1. Change the data to filter to the given year, keyed by country
    // 2. Re-sort elements to ensure smallest remain on top, as pop values may have changed
    // 3. Update position and radius, interpolated across a 1 sec (1000ms) animation
    countries
      .data(gapminder.filter(d => d.year === year), d => d.country) // <-- 🔑
      .sort((a, b) => b.pop - a.pop)
      .transition()         // <-- akin to a D3 selection, but interpolates values
        .duration(1000)     // <-- 1000 ms === 1 sec
        .ease(d3.easeCubic) // <-- sets pacing; cubic is the default, try some others!
        .attr('cx', d => x(d.fertility))
        .attr('cy', d => y(d.life_expect))
        .attr('r', d => size(d.pop));
  }

  // Extend SVG node, export setYear as a property thereof
  return Object.assign(svg.node(), { setYear });
})()
chartAnimate.setYear(yearAnimate);

With our modified chart code we can now handle updates. However, we also need to “wire up” the chart so that it is notified of changes to our year slider (here named yearAnimate). The cell above invokes setYear() in response to yearAnimate updates.

Before moving on, let’s talk about that semi-magical transition() call in our chart update function. The transition() method provides an API similar to a standard D3 selection: we can call attr() and style() to update our DOM elements. Under the hood, transitions then create interpolators that calculate intermediate values between start and end points. When transition is called, D3 automatically schedules an animation that interpolates over the specified duration.

In addition to the duration() parameter, transitions support delay() to set pauses before onset and ease() to control pacing. The default ease function uses slow-in slow-out timing along a cubic curve: items start moving slowly, speed up through the middle of the transition, then slow down before stopping.

Try setting a longer duration and using a different ease function — such as d3.easeLinear or d3.easeBounce — to experiment with animation effects.


Task 4: Create a Color Legend

Let’s now turn our attention to creating a legend. A key insight here is that legends are just visualizations, too! Here we want to visualize the scale mapping from values to colors. We can ask ourselves the same questions that we would with any visualization design task, including: what is our input data, and what visual encodings should we apply?

Our dataset uses the cryptic cluster field to encode geographic regions as integer indices. As part of our legend we would like to present informative labels, so our input “data” can be a set of index-label pairs:

const regions = [
  { index: 0, label: 'South Asia' },
  { index: 1, label: 'Europe & Central Asia' },
  { index: 2, label: 'Sub-Saharan Africa' },
  { index: 3, label: 'America' },
  { index: 4, label: 'East Asia & Pacific' },
  { index: 5, label: 'Middle East & North Africa' }
];

We could start adding code for our legend in the same cell as our chart definition. However, it would be nice to have a bit more modularity and support reuse by separating our legend design from our primary chart. Following the lead of D3’s axis generation methods, let’s build a helper method for creating a color legend. Our method can take as input a D3 selection for the container (<g>) element for the legend. Our job is to then populate this container with the legend content.

function legend(container) {
  const titlePadding = 14;  // padding between title and entries
  const entrySpacing = 16;  // spacing between legend entries
  const entryRadius = 5;    // radius of legend entry marks
  const labelOffset = 4;    // additional horizontal offset of text labels
  const baselineOffset = 4; // text baseline offset, depends on radius and font size

  const title = container.append('text')
    .attr('x', 0)
    .attr('y', 0)
    .attr('fill', 'black')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-weight', 'bold')
    .attr('font-size', '12px')
    .text('Region');

  const entries = container.selectAll('g')
    .data(regions)
    .join('g')
      .attr('transform', d => `translate(0, ${titlePadding + d.index * entrySpacing})`);

  const symbols = entries.append('circle')
    .attr('cx', entryRadius) // <-- offset symbol x-position by radius
    .attr('r', entryRadius)
    .attr('fill', d => color(d.index));

  const labels = entries.append('text')
    .attr('x', 2 * entryRadius + labelOffset) // <-- place labels to the left of symbols
    .attr('y', baselineOffset) // <-- adjust label y-position for proper alignment
    .attr('fill', 'black')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-size', '11px')
    .style('user-select', 'none') // <-- disallow selectable text
    .text(d => d.label);
}

The style of code above should hopefully be familiar by now. We first define some constants and add a single text element for the title. To create legend entries, we join our input data against a selection of <g> elements, creating a group for each legend entry. We position the entries by translating each group relative to its index. We then populate each group with a colored <circle> and a <text> label.

Note the inclusion of the style user-select: none. This CSS directive tells the browser not to allow text selection of the legend label, which will be useful to avoid distracting selections if we later add interaction. You may or may not have noticed earlier that SVG text is selectable by default. Here that includes our axis labels, axis titles, and year label! By adding user-select: none (whether using D3 or a CSS stylesheet) you can suppress these selections. Importantly, this is different from pointer-events: none, which prevents interaction entirely!

Now let’s test our legend helper! We can create a new SVG element with a container group. Complete the code below to apply our legend helper using the call() method, which invokes the provided function, passing the current selection as an argument.

display((() => {
  const svg = d3.create('svg')
    .attr('width', 200)
    .attr('height', 110);

  const legend = svg.append('g');
    // add code to add a color legend here
    // hint: our legend helper is invoked just like an axis generator

  return svg.node();
})());

Looking good! Try adjusting the various style constants in the legend helper and see how they affect the result.

While here we focused on a categorical color legend, a similar pattern can be used to create other types of legends. For example, by modifying the <circle> radius with a scale function, we could create a size legend instead. Of course, legends are common and you may tire of creating them from scratch. As D3 does not directly provide higher-level legend support, Susie Lu’s d3-legend library can come in handy!

Now that we can generate a legend, let’s add it to our chart.

const chartLegend = (() => {
  const svg = d3.create('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g')
    .attr('transform', `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(x))
    .append('text')
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .attr('x', width - margin.right)
      .attr('y', -10)
      .text('Fertility');

  svg.append('g')
    .attr('transform', `translate(${margin.left}, 0)`)
    .call(d3.axisLeft(y))
    .append('text')
      .attr('transform', `translate(20, ${margin.top}) rotate(-90)`)
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .text('Life Expectancy');

  const yearLabel = svg.append('text')
    .attr('class', 'year')
    .attr('x', 40)
    .attr('y', height - margin.bottom - 20)
    .attr('fill', '#ccc')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-weight', 500)
    .attr('font-size', 80)
    .text(1955);

  // Add and position the legend here; place legend in the upper right corner.
  // svg.append('g')

  const countries = svg
    .selectAll('circle.country')
    .data(dataInitial, d => d.country)
    .join('circle')
      .sort((a, b) => b.pop - a.pop)
      .attr('class', 'country')
      .attr('opacity', 0.75)
      .attr('fill', d => color(d.cluster))
      .attr('cx', d => x(d.fertility))
      .attr('cy', d => y(d.life_expect))
      .attr('r', d => size(d.pop));

  countries
    .append('title')
    .text(d => d.country);

  countries
    .on('mouseover', function() {
      d3.select(this).attr('stroke', '#333').attr('stroke-width', 2);
    })
    .on('mouseout', function() {
      d3.select(this).attr('stroke', null);
    });

  function setYear(year) {
    yearLabel.text(year);
    countries
      .data(gapminder.filter(d => d.year === year), d => d.country)
      .sort((a, b) => b.pop - a.pop)
      .transition()
        .duration(1000)
        .attr('cx', d => x(d.fertility))
        .attr('cy', d => y(d.life_expect))
        .attr('r', d => size(d.pop));
  }

  return Object.assign(svg.node(), { setYear });
})();

Task 5: Add and Remove Data

Handle the addition and removal of countries from the data. Above our circle elements are always in the update set upon each change of year. Modify the data join to handle items in the enter or exit sets upon year changes.

Below we modify the data so that all countries in the Americas disappear after 1960, while all East Asia & Pacific countries do not appear until 1980. Use the scaffold code below to start, and feel free to review the data join discussion or D3 Learn: Joins. The chart code includes blocks for handling enter, exit, and update sets.

Drag the slider back and forth to understand the current behavior, then add encoding and transition logic to properly handle items entering and exiting the scene. What animated effects might you apply to convey these changes?

const dataDynamic = gapminder
  .filter(d => d.cluster !== 4 || d.year >= 1980)
  .filter(d => d.cluster !== 3 || d.year <= 1960);
const yearDynamic = view(Inputs.range(years, { step: 5 }));

Average Fertility & Life Expectancy by Country in .

const chartDynamic = (function() {
  const svg = d3.create('svg')
    .attr('width', width)
    .attr('height', height);

  svg.append('g')
    .attr('transform', `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(x))
    .append('text')
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .attr('x', width - margin.right)
      .attr('y', -10)
      .text('Fertility');

  svg.append('g')
    .attr('transform', `translate(${margin.left}, 0)`)
    .call(d3.axisLeft(y))
    .append('text')
      .attr('transform', `translate(20, ${margin.top}) rotate(-90)`)
      .attr('text-anchor', 'end')
      .attr('fill', 'black')
      .attr('font-size', '12px')
      .attr('font-weight', 'bold')
      .text('Life Expectancy');

  const yearLabel = svg.append('text')
    .attr('class', 'year')
    .attr('x', 40)
    .attr('y', height - margin.bottom - 20)
    .attr('fill', '#ccc')
    .attr('font-family', 'Helvetica Neue, Arial')
    .attr('font-weight', 500)
    .attr('font-size', 80)
    .text(years[0]);

  svg.append('g')
    .attr('transform', `translate(${width - margin.right - 150}, 10)`)
    .call(legend);

  let countries = svg
    .selectAll('circle.country')
    .data(dataDynamic.filter(d => d.year === years[0]), d => d.country)
    .join('circle')
      .attr('class', 'country')
      .sort((a, b) => b.pop - a.pop)
      .attr('opacity', 0.75)
      .attr('fill', d => color(d.cluster))
      .attr('cx', d => x(d.fertility))
      .attr('cy', d => y(d.life_expect))
      .attr('r', d => size(d.pop));

  countries
    .append('title')
    .text(d => d.country);

  countries
    .on('mouseover', function() {
      d3.select(this).attr('stroke', '#333').attr('stroke-width', 2);
    })
    .on('mouseout', function() {
      d3.select(this).attr('stroke', null);
    });

  function setYear(year) {
    yearLabel.text(year);

    countries = countries
      .data(dataDynamic.filter(d => d.year === year), d => d.country)
      .join(
        // Add code to customize how countries enter the scene.
        // Idea: fade in from transparent and grow from zero size
        // Make sure new elements have their properties properly initialized!
        enter =>  enter.append('circle')
          .attr('class', 'country'),

        update => update,

        // Add code to customize how countries exit the scene.
        // Idea: fade out to transparent and shrink to zero size before removal
        exit => exit.remove()
      );

    // Animate enter + update countries to current position and size
    // Hint: If you modify opacity above, you probably want to update it here!
    countries.transition()
      .duration(1000)
      .attr('cx', d => x(d.fertility))
      .attr('cy', d => y(d.life_expect))
      .attr('r', d => size(d.pop));
  }

  return Object.assign(svg.node(), { setYear });
}());
chartDynamic.setYear(yearDynamic);

Next Steps

Want to try your hand at some more exercises? Here are some suggestions:

  1. Revise the graphic design. Above we sped along without diving into important questions of typography, sizing, and element placement. How else might you style and position the axis labels, axis titles, year label, circle colors, etc.?
  2. Make the legend interactive. Update the legend to support multiple item selections. When a user clicks or shift-clicks a legend item, data points for those regions in the chart should highlight (e.g., by de-emphasizing the other points). How might you best handle this selection state?
  3. Direct manipulation time scrubbing (extra hard!). So far we’ve used a slider widget to adjust the year. What if instead we could just grab a point and drag it through time? This is the idea behind Kondo & Collins’ DimpVis. Here is an example implemented with Vega; can you implement a similar design in D3?

Don’t forget to add, commit, and push your exercises to your GitLab repo!