Public
Edited
Jan 18, 2023
Importers
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
years = d3.extent(gapminder, (d) => d.year) // compute min and max years
Insert cell
dataInitial = gapminder.filter((d) => d.year === years[0]) // filter to initial year
Insert cell
Insert cell
width
Insert cell
Insert cell
margin = ({top: 10, right: 10, bottom: 20, left: 20})
Insert cell
Insert cell
dataInitial[0].fertility
Insert cell
[0, d3.max(gapminder, (d) => d.fertility)]
Insert cell
x = d3
.scaleLinear()
// create scale for x axis
.domain([0, d3.max(gapminder, (d) => d.fertility)])
.range([0, width])
.nice()
Insert cell
x(dataInitial[0].fertility)
Insert cell
y = d3
// TODO(vishal): create scale for y axis
.scaleLinear()
.domain([0, d3.max(gapminder, (d) => d.life_expect) + 5])
.range([height, 0])
.nice()
Insert cell
y(dataInitial[0].life_expect)
Insert cell
Insert cell
gapminder.map((d) => d.cluster)
Insert cell
d3.schemeCategory10
Insert cell
color = d3
.scaleOrdinal()
// create color encoding
.domain(gapminder.map((d) => d.cluster))
.range(d3.schemeTableau10)
Insert cell
color(dataInitial[0].cluster)
Insert cell
Insert cell
size = d3
.scaleSqrt()
// create size encoding
.domain(d3.extent(gapminder, (d) => d.pop))
.range([4, 35])
Insert cell
Insert cell
{
// create the container SVG element
const svg = d3.create("svg").attr("viewBox", viewBox);

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

// position and populate the y-axis
svg.append("g").call(d3.axisLeft(y));

// TODO(vishal): 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")
.sort((a, b) => b.pop - a.pop) // sort by population descending
.attr("cx", (d) => x(d.fertility))
.attr("cy", (d) => y(d.life_expect))
.attr("r", (d) => size(d.pop))
.attr("fill", (d) => color(d.cluster))
.attr("opacity", 0.75);

debugSelection(countries);

return svg.node();
}
Insert cell
function debugSelection(selection) {
selection.each(function (d) {
console.log(d, this);
});
}
Insert cell
Insert cell
{
const svg = d3.create("svg").attr("width", width).attr("height", height);

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

svg
.append("g")
.call(d3.axisLeft(y))
// TODO(vishal): Add y-axis title 'text' element.
.append("text")
.text("Life Expectancy")
.attr("fill", "black")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("x", 0)
.attr("text-anchor", "end")
.attr("transform", `translate(20, 0) rotate(-90)`);

// TODO(vishal): Add a background label for the current year.

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

// TODO(vishal): add a tooltip
countries.append("title").text((d) => d.country);

// TODO(vishal): Add mouse hover interactions, using D3 to update attributes directly.
countries
.on("mouseover", (evt) => {
d3.select(evt.target).attr("stroke", "#333").attr("stroke-width", 2);
})
.on("mouseout", (evt) => {
d3.select(evt.target).attr("stroke", null);
});

return svg.node();
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
years
Insert cell
viewof yearFilter = Scrubber(
// TODO(tukey): construct scrubber
d3.range(years[0], years[1] + 1, 5),
{ autoplay: false, loop: false, delay: 500 }
)
Insert cell
yearFilter
Insert cell
{
const svg = d3.create("svg").attr("viewBox", viewBox);

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

svg
.append("g")
.call(d3.axisLeft(y))
.append("text")
.attr("transform", "translate(20, 0) 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 - 20)
.attr("fill", "#ccc")
.attr("font-family", "Helvetica Neue, Arial")
.attr("font-weight", 500)
.attr("font-size", 80)
.text(yearFilter); // <-- TODO(tukey): update to use yearFilter

const countries = svg
.selectAll("circle")
.data(gapminder.filter((d) => d.year === yearFilter)) // <-- TODO(tukey): 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));

countries
.transition()
.duration(1000)
.ease(d3.easeLinear)
.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();
}
Insert cell
Insert cell
Insert cell
Insert cell
viewof yearAnimate = Scrubber(
d3.range(years[0], years[1] + 1, 5),
{ autoplay: false, delay: 1000, loop: false }
)
Insert cell
chartAnimate.changeYear(yearAnimate)
Insert cell
chartAnimate = {
const svg = d3.create("svg").attr("viewBox", viewBox);

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

svg
.append("g")
.call(d3.axisLeft(y))
.append("text")
.attr("transform", "translate(20, 0) 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 - 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")
// TODO(tukey): add key function
.data(dataInitial)
.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);
});

// TODO(tukey): update function
// update countries and animate the transition
// step 1: change the data to filter to the given year
// step 2: re-sort elements to ensure the smallest remain on top of the visualization, as pop values may have changed
// step 3: update position and radius, 1000 ms
// step 4: update the year label

function changeYear(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)
.ease(d3.easeCubic)
.attr("cx", (d) => x(d.fertility))
.attr("cy", (d) => y(d.life_expect))
.attr("r", (d) => size(d.pop));
}

// TODO(tukey): extend SVG node
// return svg.node();
return Object.assign(svg.node(), { changeYear });
}
Insert cell
(chartAnimate.changeYear(yearAnimate),
md`Average Fertility & Life Expectancy by Country in ${yearAnimate}`)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
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' }
];
Insert cell
Insert cell
Insert cell
{
const svg = d3.create('svg')
.attr('width', 200)
.attr('height', 110);
const legend = svg.append('g')
.attr('transform', 'translate(0, 10)')
.call(colorLegend); // <-- our legend helper is invoked just like an axis generator

return svg.node();
}
Insert cell
function colorLegend(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")
// TODO(tukey): create legend's title
.text("Region")
.attr("x", 0)
.attr("y", 0)
.attr("fill", "black")
.attr("font-family", "Arial")
.attr("font-size", 12)
.attr("font-weight", "bold");

const entries = container
.selectAll("g")
// TODO(tukey): create and entry for each label
.select("g")
.data(regions)
.join("g")
.attr(
"transform",
(d) => `translate(0, ${titlePadding + d.index * entrySpacing})`
);

const symbols = entries
.append("circle")
// TODO(tukey): add color indication for each label
.attr("cx", entryRadius)
.attr("r", entryRadius)
.attr("fill", (d) => color(d.index));

const labels = entries
.append("text")
.text((d) => d.label)
.attr("x", 2 * entryRadius + labelOffset)
.attr("y", baselineOffset)
.attr("fill", "black")
.attr("font-family", "Comic Sans MS")
.attr("font-size", 11);

// TODO(tukey): add text labels
}
Insert cell
Insert cell
Insert cell
chartLegend = {
const svg = d3.create("svg").attr("viewBox", viewBox);

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

svg
.append("g")
.call(d3.axisLeft(y))
.append("text")
.attr("transform", "translate(20, 0) 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 - 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; place in the upper right corner.
// TODO(tukey): add legend
svg
.append("g")
.call((container) => colorLegend(container))
.attr("transform", `translate(${width - 150}, 10)`);

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 changeYear(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)
.ease(d3.easeCubic)
.attr("cx", (d) => x(d.fertility))
.attr("cy", (d) => y(d.life_expect))
.attr("r", (d) => size(d.pop));
}

// TODO(tukey): consider using a legend library like https://observablehq.com/@d3/color-legend
return Object.assign(svg.node(), { changeYear });
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// container is a d3 selection for the container group (<g>) element
// selmodel is a selection model instance for tracking selected legend entries
function legend(container, selmodel) {
const titlePadding = 14;
const entrySpacing = 16;
const entryRadius = 5;
const labelOffset = 4;
const baselineOffset = 4;

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");

// The "on" method registers event listeners
// We update the selection model in response
const entries = container
.selectAll("g")
.data(regions)
.join("g")
.attr(
"transform",
(d) => `translate(0, ${titlePadding + d.index * entrySpacing})`
)
// TODO(vishal): handle interaction
.on("click", (evt, d) => selmodel.toggle(d.index))
.on("dblclick", (evt) => selmodel.clear());

const symbols = entries
.append("circle")
.attr("cx", entryRadius)
.attr("r", entryRadius)
.attr("fill", (d) => color(d.index));

const labels = entries
.append("text")
.attr("x", 2 * entryRadius + labelOffset)
.attr("y", baselineOffset)
.attr("fill", "black")
.attr("font-family", "Helvetica Neue, Arial")
.attr("font-size", "11px")
.style("user-select", "none")
.text((d) => d.label);

// Listen to selection model, update symbol and labels upon changes
selmodel.on("change.legend", () => {
// TODO(vishal): add effects from legend's interaction
// This is an event listener that executes when the legend selection is changed, so when something is toggled on or off.
// symbols.attr("fill", (d) => {
// if (selmodel.has(d.index)) {
// return color(d.index);
// } else {
// return "#ccc";
// }
// We can simplify this with a JavaScript ternary operator, which I really like since they're so concise
symbols.attr("fill", (d) =>
selmodel.has(d.index) ? color(d.index) : "#ccc"
);
labels.attr("fill", (d) => (selmodel.has(d.index) ? "black" : "#bbb"));
});
}
Insert cell
Insert cell
{
const selmodel = SelectionModel(); // <-- Instantiate a selection model
const svg = d3.create("svg").attr("width", 200).attr("height", 110);

svg
.append("g")
.attr("transform", "translate(0, 10)")
.call((container) => legend(container, selmodel));

return svg.node();
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
gapminder
Insert cell
// We need a messy data set for testing.
// Edit the data so that it's missing some countries for some years.
dataDynamic = gapminder
.filter((d) => d.cluster !== 4 || d.year >= 1980)
.filter((d) => d.cluster !== 3 || d.year <= 1960)
Insert cell
viewof yearDynamic = Scrubber(d3.range(years[0], years[1] + 1, 5), {
autoplay: false,
delay: 1000,
loop: false
})
Insert cell
Insert cell
chartDynamic = {
const svg = d3.create("svg").attr("viewBox", viewBox);

const selmodel = SelectionModel(); // <-- Instantiate a selection model

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

svg
.append("g")
.call(d3.axisLeft(y))
.append("text")
.attr("transform", "translate(20, 0) 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 - 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 - 150}, 10)`)
.call((container) => legend(container, selmodel));

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 changeYear(year) {
yearLabel.text(year);

countries = countries
.data(
dataDynamic.filter((d) => d.year === year),
(d) => d.country
)
// what we had before
// .join("circle")

.join(
// TODO(vishal): 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")
.attr("fill", (d) => color(d.cluster))
.attr("cx", (d) => x(d.fertility))
.attr("cy", (d) => y(d.life_expect))
.attr("r", (d) => 0),

(update) => update, // countries that still exist

// TODO(vishal): Add code to customize how countries exit the scene.
// Idea: fade out to transparent and shrink to zero size before removal
(exit) =>
exit
.transition()
.duration(1000)
.attr("r", 0)
.attr("opacity", 0)
.remove()
);

// TODO(vishal): Animate enter + update countries to current position and size
// Hint: If you modify opacity above, you probably want to update it here!
countries
.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))
.attr("opacity", 0.75);

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

return Object.assign(svg.node(), { changeYear });
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more