Public
Edited
May 16
Insert cell
Insert cell
Insert cell
Inputs.table(aiddata)
Insert cell
uniqueDonors = [...new Set(aiddata.map(aiddata => aiddata.donor))];
Insert cell
uniqueRecipients = [...new Set(aiddata.map(aiddata => aiddata.recipient))];
Insert cell
aiddata = {
const data = await d3.csv(googleSheetCsvUrl, row => ({
yearDate: d3.timeParse('%Y')(row.year),
yearInt: +row.year,
donor: row.donor,
recipient: row.recipient,
amount: +row.commitment_amount_usd_constant,
purpose: row.coalesced_purpose_name,
}));
data.columns = Object.keys(data[0]);
return data;
}
Insert cell
Insert cell
geoJSON = FileAttachment("countries-50m.json").json()
Insert cell
Insert cell
Insert cell
Insert cell
import {legend, swatches} from "@d3/color-legend"
Insert cell
Insert cell
Insert cell
{
const yearRange = d3.extent(aiddata, d => d.yearInt);
const yearStart = yearRange[0];
const yearEnd = yearRange[1];
const years = d3.range(yearStart, yearEnd + 1);
const allCountries = Array.from(new Set([
...aiddata.map(d => d.donor),
...aiddata.map(d => d.recipient)
])).filter(d => d !== "Unspecified" && d !== "");
const donationsByCountryYear = [];
allCountries.forEach(country => {
years.forEach(year => {
donationsByCountryYear.push({
country,
year,
donated: 0,
received: 0
});
});
});
aiddata.forEach(d => {
const donorEntry = donationsByCountryYear.find(entry =>
entry.country === d.donor && entry.year === d.yearInt
);
if (donorEntry) {
donorEntry.donated += d.amount;
}
const recipientEntry = donationsByCountryYear.find(entry =>
entry.country === d.recipient && entry.year === d.yearInt
);
if (recipientEntry) {
recipientEntry.received += d.amount;
}
});
donationsByCountryYear.forEach(d => {
d.netDonation = d.donated - d.received;
d.totalActivity = d.donated + d.received;
});
const donationsByCountry = d3.rollup(
donationsByCountryYear,
v => ({
totalDonated: d3.sum(v, d => d.donated),
totalReceived: d3.sum(v, d => d.received),
netDonation: d3.sum(v, d => d.netDonation),
totalActivity: d3.sum(v, d => d.totalActivity)
}),
d => d.country
);
const countryTotals = Array.from(donationsByCountry, ([country, data]) => ({
country,
...data,
donorRatio: data.totalDonated / (data.totalActivity || 1),
balanceRatio: Math.abs(data.netDonation) / (data.totalActivity || 1)
}));
const topCountries = countryTotals
.sort((a, b) => b.totalActivity - a.totalActivity)
.slice(0, 20);
const balancedCountries = countryTotals
.filter(d => d.totalActivity > 1e9)
.sort((a, b) => a.balanceRatio - b.balanceRatio)
.slice(0, 5);
const periodSize = 5;
const periods = d3.range(
Math.floor(yearStart / periodSize) * periodSize,
Math.ceil(yearEnd / periodSize) * periodSize,
periodSize
);
const donationsByPeriod = [];
allCountries.forEach(country => {
periods.forEach(period => {
const yearsInPeriod = years.filter(y => y >= period && y < period + periodSize);
const periodData = donationsByCountryYear.filter(
d => d.country === country && yearsInPeriod.includes(d.year)
);
if (periodData.length > 0) {
donationsByPeriod.push({
country,
period,
periodEnd: period + periodSize - 1,
donated: d3.sum(periodData, d => d.donated),
received: d3.sum(periodData, d => d.received),
netDonation: d3.sum(periodData, d => d.netDonation),
totalActivity: d3.sum(periodData, d => d.totalActivity)
});
}
});
});
const roleChangeByCountry = d3.rollup(
donationsByPeriod.filter(d => d.totalActivity > 0),
v => {
if (v.length < 3) return { roleChangeScore: 0 };
const x = v.map(d => d.period);
const y = v.map(d => d.netDonation);
const xMean = d3.mean(x);
const yMean = d3.mean(y);
const numerator = d3.sum(x.map((xi, i) => (xi - xMean) * (y[i] - yMean)));
const denominator = d3.sum(x.map(xi => Math.pow(xi - xMean, 2)));
const slope = denominator === 0 ? 0 : numerator / denominator;
const totalActivity = d3.sum(v, d => d.totalActivity);
const roleChangeScore = Math.abs(slope) / (totalActivity || 1) * 1e10;
return {
roleChangeScore,
periods: v.length,
firstPeriod: d3.min(v, d => d.period),
lastPeriod: d3.max(v, d => d.period),
firstNetDonation: v.find(d => d.period === d3.min(v, d => d.period))?.netDonation || 0,
lastNetDonation: v.find(d => d.period === d3.max(v, d => d.period))?.netDonation || 0,
totalActivity
};
},
d => d.country
);
const roleChanges = Array.from(roleChangeByCountry, ([country, data]) => ({
country,
...data,
switched: Math.sign(data.firstNetDonation) !== Math.sign(data.lastNetDonation)
}))
.filter(d => d.periods >= 3 && d.totalActivity > 1e9)
.sort((a, b) => b.roleChangeScore - a.roleChangeScore);
const topRoleChanges = roleChanges.slice(0, 5);
const countriesToShow = new Set([
...topCountries.map(d => d.country),
...balancedCountries.map(d => d.country),
...topRoleChanges.map(d => d.country)
]);
const filteredData = donationsByCountryYear.filter(d =>
countriesToShow.has(d.country)
);
let cumulativeData = [];
allCountries.forEach(country => {
if (countriesToShow.has(country)) {
let cumulativeNet = 0;
years.forEach(year => {
const entry = donationsByCountryYear.find(d => d.country === country && d.year === year);
if (entry) {
cumulativeNet += entry.netDonation;
cumulativeData.push({
country,
year,
cumulativeNet,
netThisYear: entry.netDonation,
donated: entry.donated,
received: entry.received
});
}
});
}
});
const width = 1200;
const height = 1100;
const margin = { top: 80, right: 250, bottom: 80, left: 80 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
svg.append("text")
.attr("x", width / 2)
.attr("y", 30)
.attr("text-anchor", "middle")
.attr("font-size", "24px")
.attr("font-weight", "bold")
.text("Country Donation Patterns Over Time");
svg.append("text")
.attr("x", width / 2)
.attr("y", 55)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.text("Comparing donations given vs. received across countries and time");
const xScale = d3.scaleLinear()
.domain(d3.extent(years))
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain(d3.extent(cumulativeData, d => d.cumulativeNet))
.nice()
.range([innerHeight, 0]);
const colorScale = d3.scaleOrdinal()
.domain(Array.from(countriesToShow))
.range(d3.schemeTableau10);
const mainGroup = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
mainGroup.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(0))
.attr("y2", yScale(0))
.attr("stroke", "#888")
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "6,4");
const xAxis = d3.axisBottom(xScale)
.tickFormat(d => d.toString())
.ticks(10);
mainGroup.append("g")
.attr("transform", `translate(0, ${innerHeight})`)
.call(xAxis)
.attr("font-size", "12px");
const formatAmount = (amount) => {
if (Math.abs(amount) >= 1e9) {
return `${(amount / 1e9).toFixed(1)}B`;
} else if (Math.abs(amount) >= 1e6) {
return `${(amount / 1e6).toFixed(1)}M`;
} else {
return amount.toFixed(0);
}
};
const yAxis = d3.axisLeft(yScale)
.tickFormat(formatAmount);
mainGroup.append("g")
.call(yAxis)
.attr("font-size", "12px");
mainGroup.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 40)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.text("Year");
mainGroup.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -50)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.text("Cumulative Net Donation (USD)");
const line = d3.line()
.x(d => xScale(d.year))
.y(d => yScale(d.cumulativeNet));
const countryData = d3.group(cumulativeData, d => d.country);
const paths = mainGroup.append("g")
.selectAll("path")
.data(countryData)
.join("path")
.attr("d", ([_, data]) => line(data))
.attr("fill", "none")
.attr("stroke", ([country, _]) => colorScale(country))
.attr("stroke-width", 2.5)
.attr("stroke-opacity", 0.8);
const legendGroup = svg.append("g")
.attr("transform", `translate(${width - margin.right + 20}, ${margin.top + 20})`);
legendGroup.append("text")
.attr("x", 0)
.attr("y", -10)
.attr("font-weight", "bold")
.attr("font-size", "14px")
.text("Countries");
const legendEntries = legendGroup.selectAll("g")
.data(Array.from(countryData.keys()))
.join("g")
.attr("transform", (d, i) => `translate(0, ${i * 20})`);
legendEntries.append("rect")
.attr("width", 15)
.attr("height", 15)
.attr("fill", d => colorScale(d));
legendEntries.append("text")
.attr("x", 25)
.attr("y", 12)
.attr("font-size", "12px")
.text(d => d);
const annotationsGroup = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top + innerHeight + 100})`);
const sections = [
{
title: "Q1: Donation patterns over time",
text: "The lines show cumulative net donations (given - received) over time. Upward slope means giving more than receiving."
},
{
title: "Q2: Mostly donors vs. mostly recipients",
text: "Countries above zero line are net donors, below are net recipients. Steeper slopes indicate greater imbalance."
},
{
title: "Q3: Balanced countries",
text: `The most balanced countries (giving ≈ receiving) are: ${balancedCountries.slice(0, 3).map(d => d.country).join(", ")}`
},
{
title: "Q4: Role changes",
text: `Countries that changed roles (donor to recipient or vice versa): ${topRoleChanges.filter(d => d.switched).slice(0, 3).map(d => d.country).join(", ")}`
}
];
sections.forEach((section, i) => {
const sectionGroup = annotationsGroup.append("g")
.attr("transform", `translate(${(i % 2) * (innerWidth / 2)}, ${Math.floor(i / 2) * 55})`);
sectionGroup.append("text")
.attr("font-weight", "bold")
.attr("font-size", "14px")
.text(section.title);
sectionGroup.append("text")
.attr("y", 20)
.attr("font-size", "12px")
.attr("width", innerWidth / 2 - 20)
.text(section.text);
});
const tooltip = svg.append("g")
.attr("class", "tooltip")
.style("display", "none");
tooltip.append("rect")
.attr("width", 200)
.attr("height", 80)
.attr("fill", "white")
.attr("stroke", "#888")
.attr("rx", 5)
.attr("ry", 5)
.attr("opacity", 0.9);
const tooltipTitle = tooltip.append("text")
.attr("x", 10)
.attr("y", 20)
.attr("font-weight", "bold")
.attr("font-size", "14px");
const tooltipDonated = tooltip.append("text")
.attr("x", 10)
.attr("y", 40)
.attr("font-size", "12px");
const tooltipReceived = tooltip.append("text")
.attr("x", 10)
.attr("y", 60)
.attr("font-size", "12px");
const tooltipCumulative = tooltip.append("text")
.attr("x", 10)
.attr("y", 80)
.attr("font-size", "12px")
.attr("font-weight", "bold");
const delaunay = d3.Delaunay.from(
cumulativeData,
d => xScale(d.year),
d => yScale(d.cumulativeNet)
);
mainGroup.append("rect")
.attr("width", innerWidth)
.attr("height", innerHeight)
.attr("fill", "transparent")
.on("mousemove", function(event) {
const [mx, my] = d3.pointer(event, this);
const index = delaunay.find(mx, my);
const d = cumulativeData[index];
if (d) {
tooltipTitle.text(`${d.country} (${d.year})`);
tooltipDonated.text(`Donated: ${formatAmount(d.donated)}`);
tooltipReceived.text(`Received: ${formatAmount(d.received)}`);
tooltipCumulative.text(`Cumulative Net: ${formatAmount(d.cumulativeNet)}`);
tooltip
.attr("transform", `translate(${margin.left + mx + 20}, ${margin.top + my - 90})`)
.style("display", "block");
paths.attr("stroke-opacity", ([country, _]) => country === d.country ? 1 : 0.2);
paths.attr("stroke-width", ([country, _]) => country === d.country ? 4 : 1);
}
})
.on("mouseout", function() {
tooltip.style("display", "none");
paths.attr("stroke-opacity", 0.8);
paths.attr("stroke-width", 2.5);
});
return svg.node();
}
Insert cell
{
const filteredData = aiddata.filter(d => d.purpose !== "UNSPECIFIED");
const purposeTotals = d3.rollup(
filteredData,
v => d3.sum(v, d => d.amount),
d => d.purpose
);
const purposeArray = Array.from(purposeTotals, ([purpose, amount]) => ({ purpose, amount }))
.sort((a, b) => b.amount - a.amount);
const top10Purposes = purposeArray.slice(0, 10);
const top10PurposeSet = new Set(top10Purposes.map(p => p.purpose));
const yearlyData = d3.rollup(
filteredData.filter(d => top10PurposeSet.has(d.purpose)),
v => d3.sum(v, d => d.amount),
d => d.yearInt,
d => d.purpose
);
const years = Array.from(yearlyData.keys()).sort(d3.ascending);
const timeSeriesData = years.map(year => {
const row = { year };
const pm = yearlyData.get(year) ?? new Map();
top10Purposes.forEach(p => { row[p.purpose] = pm.get(p.purpose) ?? 0; });
row.total = d3.sum(top10Purposes, p => row[p.purpose]);
top10Purposes.forEach(p => { row[`${p.purpose}_pct`] = (row[p.purpose] / row.total) * 100; });
return row;
});
const width = 1400;
const plotHeight = 700;
const insightsHeight = 150;
const margin = { top: 80, right: 480, bottom: 100, left: 90 };
const innerW = width - margin.left - margin.right;
const innerH = plotHeight;
const height = margin.top + innerH + insightsHeight + margin.bottom;
const xScale = d3.scaleLinear()
.domain(d3.extent(timeSeriesData, d => d.year))
.range([0, innerW]);
const yScalePct = d3.scaleLinear().domain([0, 100]).nice()
.range([innerH, 0]);
const colorScale = d3.scaleOrdinal()
.domain(top10Purposes.map(p => p.purpose))
.range(d3.schemeTableau10);
const stack = d3.stack()
.keys(top10Purposes.map(p => p.purpose))
.offset(d3.stackOffsetExpand);
const stacked = stack(timeSeriesData);
const area = d3.area()
.x(d => xScale(d.data.year))
.y0(d => yScalePct(d[0] * 100))
.y1(d => yScalePct(d[1] * 100))
.curve(d3.curveMonotoneX);
const container = d3.create("div")
.style("position", "relative")
.style("max-width", "100%");
const svg = container.append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", width)
.attr("height", height)
.style("font-family", "Arial, sans-serif");
const tooltip = container.append("div")
.style("position", "absolute")
.style("pointer-events", "none")
.style("background", "rgba(0,0,0,0.8)")
.style("color", "#fff")
.style("padding", "6px 10px")
.style("border-radius", "4px")
.style("font-size", "12px")
.style("visibility", "hidden");
svg.append("text")
.attr("x", width/2).attr("y", 30)
.attr("text-anchor", "middle")
.attr("font-size", 28).attr("font-weight", "bold")
.text("Top 10 Donation Purposes Over Time");
svg.append("text")
.attr("x", width/2).attr("y", 60)
.attr("text-anchor", "middle")
.attr("font-size", 17)
.text("Relative distribution and trends in international aid contributions by purpose");
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const areas = g.selectAll("path")
.data(stacked, d => d.key)
.join("path")
.attr("fill", d => colorScale(d.key))
.attr("d", area)
.attr("opacity", 0.85);
g.append("g")
.attr("transform", `translate(0,${innerH})`)
.call(d3.axisBottom(xScale).tickFormat(d3.format("d"))
.ticks(Math.min(years.length, 15)))
.selectAll("text").attr("font-size", 12);
g.append("g")
.call(d3.axisLeft(yScalePct).tickFormat(d => `${d}%`).ticks(10))
.selectAll("text").attr("font-size", 12);
g.append("g").attr("class","grid")
.selectAll("line").data(yScalePct.ticks(10)).join("line")
.attr("x1",0).attr("x2",innerW)
.attr("y1",d=>yScalePct(d)).attr("y2",d=>yScalePct(d))
.attr("stroke","#ddd").attr("stroke-width",0.5);
svg.append("text")
.attr("x", width/2)
.attr("y", height - margin.bottom + 30)
.attr("text-anchor","middle")
.attr("font-size",16)
.text("Year");
svg.append("text")
.attr("transform","rotate(-90)")
.attr("x", -(margin.top + innerH/2))
.attr("y", 25)
.attr("text-anchor","middle")
.attr("font-size",16)
.text("Percentage of Total Donations");
const legendX = width - margin.right + 30;
const legend = svg.append("g")
.attr("transform", `translate(${legendX}, ${margin.top})`);
legend.append("text")
.attr("font-size", 18)
.attr("font-weight", "bold")
.text("Top 10 Donation Purposes");
legend.append("text")
.attr("y", 25)
.attr("font-size", 14)
.text("By total amount across all years");
const fmt = d3.format("$.2~s");
function wrap(textSel, width) {
textSel.each(function() {
const text = d3.select(this);
const words = text.text().split(/\s+/).reverse();
let word, line = [], lineNumber = 0;
const lineHeight = 1.1;
const y = text.attr("y");
const x = text.attr("x");
let tspan = text.text(null)
.append("tspan")
.attr("x", x)
.attr("y", y);
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", `${++lineNumber * lineHeight}em`)
.text(word);
}
}
});
}
top10Purposes.forEach((p, i) => {
const row = legend.append("g")
.attr("transform", `translate(0, ${i * 32 + 45})`);
row.append("rect")
.attr("width", 20)
.attr("height", 20)
.attr("fill", colorScale(p.purpose));
row.append("text")
.attr("x", 30)
.attr("y", 15)
.attr("font-size", 14)
.text(`${p.purpose} (${fmt(p.amount)})`)
.call(wrap, 320);
});
const yearIdx = new Map(years.map((y,i)=>[y,i]));
function updateTip(event, d) {
const [mx] = d3.pointer(event, g.node());
const rawYear = xScale.invert(mx);
const year = years.reduce((a,b)=>Math.abs(b-rawYear)<Math.abs(a-rawYear)?b:a);
const row = timeSeriesData[yearIdx.get(year)];
const purpose = d.key;
tooltip.html(`<strong>${purpose}</strong><br/>
Year: <b>${year}</b><br/>
Amount: ${fmt(row[purpose])}<br/>
Share: ${row[`${purpose}_pct`].toFixed(1)}%`)
.style("left", `${event.clientX + 14}px`)
.style("top", `${event.clientY + 14}px`)
.style("visibility", "visible");
}
areas.on("mouseover", (e,d)=>{ areas.attr("opacity", x=>x.key===d.key?1:0.15); updateTip(e,d); })
.on("mousemove", updateTip)
.on("mouseout", ()=>{ areas.attr("opacity",0.85); tooltip.style("visibility","hidden"); });
const insightsY = margin.top + innerH + 40;
const insights = svg.append("g")
.attr("transform", `translate(${margin.left},${insightsY})`);
insights.append("rect")
.attr("width", innerW)
.attr("height", insightsHeight)
.attr("rx", 6)
.attr("fill", "#f8f8f8")
.attr("stroke", "#ddd");
const bullet = ["Key Insights:",
"Q1: The legend identifies the dominant purposes (Transportation & Power generation).",
"Q2: Donation priorities shift from infrastructure to energy, then diversify later.",
"Q3: Tooltips reveal ≈5–7-year cycles in Transportation & Power-generation funding."];
bullet.forEach((txt,i)=> insights.append("text")
.attr("x", i===0?15:25)
.attr("y", 28 + i*24)
.attr("font-size", i===0?16:14)
.attr("font-weight", i===0?"bold":null)
.text(txt));
return container.node();
}
Insert cell
{
const amountField = "amount";
const yearField = "yearInt";
const countryField = "recipient";
const width = 960;
const height = 550;
const margin = { top: 70, right: 20, bottom: 40, left: 20 };
const nameToIso = new Map();
geoJSON.features.forEach(f => {
const iso = f.properties.ADM0_A3;
[f.properties.NAME, f.properties.NAME_LONG, iso].forEach(n => {
if (n) nameToIso.set(n.trim().toLowerCase(), iso);
});
});
const byYearCountry = new Map();
let years = new Set();
aiddata.forEach(d => {
const yr = +d[yearField];
const iso = nameToIso.get(String(d[countryField]).toLowerCase().trim());
if (!iso) return;
years.add(yr);
if (!byYearCountry.has(yr)) byYearCountry.set(yr, new Map());
const m = byYearCountry.get(yr);
m.set(iso, (m.get(iso) || 0) + +d[amountField]);
});
years = Array.from(years).sort(d3.ascending);
const maxDonation = d3.max(Array.from(byYearCountry.values(), m =>
d3.max(Array.from(m.values()))
));
const color = d3.scaleSequential()
.domain([0, maxDonation])
.interpolator(d3.interpolateBlues);
const container = d3.create("div");
const title = container.append("h3")
.style("margin","0 0 10px")
.style("text-align","center")
.text(`Donations received in year: ${years[0]}`);
const slider = container.append("input")
.attr("type","range")
.attr("min", 0)
.attr("max", years.length-1)
.attr("value", 0)
.style("width", `${width}px`)
.style("margin-bottom","10px");
const svg = container.append("svg")
.attr("viewBox", [0,0,width,height])
.attr("width", width)
.attr("height", height);
const projection = d3.geoNaturalEarth1().fitSize(
[width, height - margin.top - margin.bottom], geoJSON);
const path = d3.geoPath(projection);
const gMap = svg.append("g").attr("transform", `translate(0,${margin.top})`);
const countries = gMap.selectAll("path")
.data(geoJSON.features)
.enter().append("path")
.attr("d", path)
.attr("stroke", "#555")
.attr("stroke-width", 0.25);
const tooltip = container.append("div")
.style("position","absolute")
.style("pointer-events","none")
.style("background","white")
.style("border","1px solid #333")
.style("padding","6px 8px")
.style("border-radius","4px")
.style("font","12px sans-serif")
.style("display","none");
const legendW = 220, legendH = 8;
const legendX = d3.scaleLinear()
.domain(color.domain())
.range([0, legendW]);
const legend = svg.append("g")
.attr("transform", `translate(${width - legendW - margin.right},${margin.top - 30})`);
legend.append("defs").append("linearGradient")
.attr("id","grad")
.selectAll("stop")
.data(d3.ticks(0,1,10))
.enter().append("stop")
.attr("offset", d => d)
.attr("stop-color", d => color(d * maxDonation));
legend.append("rect")
.attr("width", legendW)
.attr("height", legendH)
.style("fill","url(#grad)");
legend.append("g")
.attr("transform", `translate(0,${legendH})`)
.call(d3.axisBottom(legendX)
.ticks(5)
.tickFormat(d => d3.format("$,.2s")(d)))
.selectAll("text")
.style("font-size","10px");
legend.append("text")
.attr("y", -4)
.attr("x", 0)
.style("font-size","11px")
.style("font-weight","bold")
.text("Donations");
update(+slider.property("value"));
slider.on("input", function() { update(+this.value); });
function update(pos){
const yr = years[pos];
const data = byYearCountry.get(yr) || new Map();
title.text(`Donations received in year: ${yr}`);
countries
.attr("fill", d => {
const amount = data.get(d.properties.ADM0_A3) || 0;
return amount ? color(amount) : "#eee";
})
.on("mousemove", (event,d) => {
const amount = data.get(d.properties.ADM0_A3) || 0;
if(!amount) { tooltip.style("display","none"); return; }
tooltip
.style("display","block")
.html(`<strong>${d.properties.NAME}</strong><br/>Donations: ${d3.format("$,.2f")(amount)}`)
.style("left", (event.pageX + 12) + "px")
.style("top", (event.pageY - 28) + "px");
})
.on("mouseleave", () => tooltip.style("display","none"));
}
return container.node();
}
Insert cell
Insert cell
d3 = require('d3@7')
Insert cell
googleSheetCsvUrl = 'https://docs.google.com/spreadsheets/d/1YiuHdfZv_JZ-igOemKJMRaU8dkucfmHxOP6Od3FraW8/gviz/tq?tqx=out:csv'
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