Public
Edited
May 26
Insert cell
Insert cell
Insert cell
Inputs.table(aiddata)
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
geoJSON = FileAttachment("countries-50m.json").json()
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function getCountryCoordinates(countryName, geoJSON) {
const feature = geoJSON.features.find(f =>
f.properties.NAME === countryName ||
f.properties.NAME_EN === countryName ||
f.properties.ADMIN === countryName
);
if (feature) {
const bounds = d3.geoBounds(feature);
return d3.geoCentroid(feature);
}
return null;
}
Insert cell
flowMap = {
const width = 1000;
const height = 600;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const projection = d3.geoNaturalEarth1()
.scale(150)
.translate([width / 2, height / 2]);
const path = d3.geoPath().projection(projection);
svg.append("g")
.selectAll("path")
.data(geoJSON.features)
.enter().append("path")
.attr("d", path)
.attr("fill", "#f0f0f0")
.attr("stroke", "#ccc")
.attr("stroke-width", 0.5);
const filteredData = aiddata.filter(d =>
top20Donors.includes(d.donor) && top10Recipients.includes(d.recipient)
);
const flows = d3.rollup(
filteredData,
v => d3.sum(v, d => d.amount),
d => d.donor,
d => d.recipient
);
const arcData = [];
flows.forEach((recipients, donor) => {
const donorCoords = getCountryCoordinates(donor, geoJSON);
if (donorCoords) {
recipients.forEach((amount, recipient) => {
const recipientCoords = getCountryCoordinates(recipient, geoJSON);
if (recipientCoords) {
arcData.push({
source: donorCoords,
target: recipientCoords,
donor,
recipient,
amount
});
}
});
}
});
const maxAmount = d3.max(arcData, d => d.amount);
const strokeScale = d3.scaleLinear()
.domain([0, maxAmount])
.range([0.5, 8]);
const opacityScale = d3.scaleLinear()
.domain([0, maxAmount])
.range([0.2, 0.8]);
function createArc(source, target) {
const sourceProj = projection(source);
const targetProj = projection(target);
if (!sourceProj || !targetProj) return null;
const dx = targetProj[0] - sourceProj[0];
const dy = targetProj[1] - sourceProj[1];
const dr = Math.sqrt(dx * dx + dy * dy) * 0.3;
return `M${sourceProj[0]},${sourceProj[1]}A${dr},${dr} 0 0,1 ${targetProj[0]},${targetProj[1]}`;
}
svg.append("g")
.selectAll("path")
.data(arcData.filter(d => d.amount > maxAmount * 0.05))
.enter().append("path")
.attr("d", d => createArc(d.source, d.target))
.attr("fill", "none")
.attr("stroke", "#ff6b35")
.attr("stroke-width", d => strokeScale(d.amount))
.attr("opacity", d => opacityScale(d.amount))
.append("title")
.text(d => `${d.donor} → ${d.recipient}: $${(d.amount/1e9).toFixed(2)}B`);
const countryPoints = [];
[...top20Donors, ...top10Recipients].forEach(country => {
const coords = getCountryCoordinates(country, geoJSON);
if (coords) {
const totalDonated = d3.sum(filteredData.filter(d => d.donor === country), d => d.amount);
const totalReceived = d3.sum(filteredData.filter(d => d.recipient === country), d => d.amount);
countryPoints.push({
country,
coords: projection(coords),
totalDonated,
totalReceived,
isDonor: top20Donors.includes(country),
isRecipient: top10Recipients.includes(country)
});
}
});
const radiusScale = d3.scaleSqrt()
.domain([0, d3.max(countryPoints, d => Math.max(d.totalDonated, d.totalReceived))])
.range([3, 15]);
svg.append("g")
.selectAll("circle")
.data(countryPoints.filter(d => d.coords))
.enter().append("circle")
.attr("cx", d => d.coords[0])
.attr("cy", d => d.coords[1])
.attr("r", d => radiusScale(Math.max(d.totalDonated, d.totalReceived)))
.attr("fill", d => d.isDonor && d.isRecipient ? "#9c27b0" : d.isDonor ? "#2196f3" : "#ff9800")
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.attr("opacity", 0.8)
.append("title")
.text(d => `${d.country}\nDonado: $${(d.totalDonated/1e9).toFixed(2)}B\nRecibido: $${(d.totalReceived/1e9).toFixed(2)}B`);
const legend = svg.append("g")
.attr("transform", "translate(20, 20)");
legend.append("circle").attr("cx", 0).attr("cy", 0).attr("r", 6).attr("fill", "#2196f3");
legend.append("text").attr("x", 15).attr("y", 5).text("Donantes").style("font-size", "12px");
legend.append("circle").attr("cx", 0).attr("cy", 20).attr("r", 6).attr("fill", "#ff9800");
legend.append("text").attr("x", 15).attr("y", 25).text("Receptores").style("font-size", "12px");
legend.append("circle").attr("cx", 0).attr("cy", 40).attr("r", 6).attr("fill", "#9c27b0");
legend.append("text").attr("x", 15).attr("y", 45).text("Ambos").style("font-size", "12px");
return svg.node();
}
Insert cell
Insert cell
top20Donors = {
const donorTotals = d3.rollup(aiddata, v => d3.sum(v, d => d.amount), d => d.donor);
return Array.from(donorTotals, ([country, total]) => ({country, total}))
.sort((a, b) => b.total - a.total)
.slice(0, 20)
.map(d => d.country);
}
Insert cell
top10Recipients = {
const recipientTotals = d3.rollup(aiddata, v => d3.sum(v, d => d.amount), d => d.recipient);
return Array.from(recipientTotals, ([country, total]) => ({country, total}))
.sort((a, b) => b.total - a.total)
.slice(0, 10)
.map(d => d.country);
}
Insert cell
chordDiagram = {
const width = 800;
const height = 800;
const outerRadius = Math.min(width, height) * 0.4;
const innerRadius = outerRadius - 20;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const g = svg.append("g")
.attr("transform", `translate(${width/2},${height/2})`);
const filteredData = aiddata.filter(d =>
top20Donors.includes(d.donor) && top10Recipients.includes(d.recipient)
);
const countries = [...new Set([...top20Donors, ...top10Recipients])];
const matrix = Array(countries.length).fill().map(() => Array(countries.length).fill(0));
const flows = d3.rollup(
filteredData,
v => d3.sum(v, d => d.amount),
d => d.donor,
d => d.recipient
);
flows.forEach((recipients, donor) => {
const donorIdx = countries.indexOf(donor);
recipients.forEach((amount, recipient) => {
const recipientIdx = countries.indexOf(recipient);
if (donorIdx !== -1 && recipientIdx !== -1) {
matrix[donorIdx][recipientIdx] = amount;
}
});
});
const chord = d3.chord()
.padAngle(0.05)
.sortSubgroups(d3.descending);
const chords = chord(matrix);
const arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
const ribbon = d3.ribbon()
.radius(innerRadius);
const donorColor = d3.scaleOrdinal(d3.schemeBlues[9].slice(2)).domain(top20Donors);
const recipientColor = d3.scaleOrdinal(d3.schemeOranges[9].slice(2)).domain(top10Recipients);
const group = g.append("g")
.selectAll("g")
.data(chords.groups)
.enter().append("g");
group.append("path")
.style("fill", (d, i) => {
const country = countries[i];
return top20Donors.includes(country) ? donorColor(country) : recipientColor(country);
})
.style("stroke", "#fff")
.attr("d", arc);
group.append("text")
.each(d => { d.angle = (d.startAngle + d.endAngle) / 2; })
.attr("dy", ".35em")
.attr("transform", d => `
rotate(${(d.angle * 180 / Math.PI - 90)})
translate(${outerRadius + 10})
${d.angle > Math.PI ? "rotate(180)" : ""}
`)
.style("text-anchor", d => d.angle > Math.PI ? "end" : null)
.style("font-size", "10px")
.text((d, i) => countries[i]);
g.append("g")
.selectAll("path")
.data(chords)
.enter().append("path")
.attr("d", ribbon)
.style("fill", d => donorColor(countries[d.source.index]))
.style("opacity", 0.7);
return svg.node();
}
Insert cell
Insert cell
purposeChoropleth = {
const width = 1200;
const height = 700;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const projection = d3.geoNaturalEarth1()
.scale(180)
.translate([width / 2, height / 2]);
const path = d3.geoPath().projection(projection);
const filteredData = aiddata.filter(d =>
top20Donors.includes(d.donor) &&
top10Recipients.includes(d.recipient) &&
top5Purposes.includes(d.purpose)
);
const countryPurposeData = d3.rollup(
filteredData,
v => {
const byPurpose = d3.rollup(v, vv => d3.sum(vv, d => d.amount), d => d.purpose);
const total = d3.sum(v, d => d.amount);
const mainPurpose = Array.from(byPurpose, ([purpose, amount]) => ({purpose, amount}))
.sort((a, b) => b.amount - a.amount)[0];
return {
total,
mainPurpose: mainPurpose ? mainPurpose.purpose : null,
purposes: byPurpose,
totalDonated: d3.sum(v.filter(d => top20Donors.includes(d.donor)), d => d.amount),
totalReceived: d3.sum(v.filter(d => top10Recipients.includes(d.recipient)), d => d.amount)
};
},
d => d.recipient
);
const purposeColor = d3.scaleOrdinal()
.domain(top5Purposes)
.range(["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"]);
const maxTotal = d3.max(Array.from(countryPurposeData.values()), d => d.total);
const intensityScale = d3.scaleLinear()
.domain([0, maxTotal])
.range([0.2, 1]);
svg.append("g")
.selectAll("path")
.data(geoJSON.features)
.enter().append("path")
.attr("d", path)
.attr("fill", d => {
const countryName = d.properties.NAME || d.properties.NAME_EN || d.properties.ADMIN;
const data = countryPurposeData.get(countryName);
if (data && data.mainPurpose) {
const baseColor = d3.color(purposeColor(data.mainPurpose));
baseColor.opacity = intensityScale(data.total);
return baseColor.toString();
}
return "#f5f5f5";
})
.attr("stroke", "#fff")
.attr("stroke-width", 0.5)
.append("title")
.text(d => {
const countryName = d.properties.NAME || d.properties.NAME_EN || d.properties.ADMIN;
const data = countryPurposeData.get(countryName);
if (data) {
const purposeBreakdown = Array.from(data.purposes, ([purpose, amount]) =>
`${purpose}: $${(amount/1e9).toFixed(2)}B`
).join('\n');
return `${countryName}\nPropósito principal: ${data.mainPurpose}\nTotal: $${(data.total/1e9).toFixed(2)}B\n\n${purposeBreakdown}`;
}
return countryName;
});
const legend = svg.append("g")
.attr("transform", "translate(50, 50)");
legend.append("text")
.attr("x", 0)
.attr("y", -10)
.style("font-weight", "bold")
.style("font-size", "14px")
.text("Propósito Principal de Donaciones");
top5Purposes.forEach((purpose, i) => {
const legendItem = legend.append("g")
.attr("transform", `translate(0, ${i * 25})`);
legendItem.append("rect")
.attr("width", 18)
.attr("height", 18)
.attr("fill", purposeColor(purpose));
legendItem.append("text")
.attr("x", 25)
.attr("y", 9)
.attr("dy", "0.35em")
.style("font-size", "12px")
.text(purpose);
});
const intensityLegend = svg.append("g")
.attr("transform", "translate(50, 250)");
intensityLegend.append("text")
.attr("x", 0)
.attr("y", -10)
.style("font-weight", "bold")
.style("font-size", "14px")
.text("Intensidad (Monto Total)");
const gradientScale = d3.scaleLinear()
.domain([0, 1])
.range([0.2, 1]);
[0.2, 0.4, 0.6, 0.8, 1].forEach((opacity, i) => {
intensityLegend.append("rect")
.attr("x", i * 25)
.attr("y", 0)
.attr("width", 20)
.attr("height", 15)
.attr("fill", "#1f77b4")
.attr("opacity", opacity);
});
intensityLegend.append("text")
.attr("x", 0)
.attr("y", 25)
.style("font-size", "10px")
.text("Bajo");
intensityLegend.append("text")
.attr("x", 80)
.attr("y", 25)
.style("font-size", "10px")
.text("Alto");
return svg.node();
}
Insert cell
top5Purposes = {
const purposeTotals = d3.rollup(aiddata, v => d3.sum(v, d => d.amount), d => d.purpose);
return Array.from(purposeTotals, ([purpose, total]) => ({purpose, total}))
.sort((a, b) => b.total - a.total)
.slice(0, 5)
.map(d => d.purpose);
}
Insert cell
Insert cell
purposeMatrix = {
const margin = {top: 100, right: 100, bottom: 100, left: 150};
const width = 1000 - margin.left - margin.right;
const height = 600 - margin.bottom - margin.top;
const svg = d3.create("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.bottom + margin.top);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const filteredData = aiddata.filter(d =>
top20Donors.includes(d.donor) &&
top10Recipients.includes(d.recipient) &&
top5Purposes.includes(d.purpose)
);
const matrixData = [];
const donorRecipientPurpose = d3.rollup(
filteredData,
v => {
const byPurpose = d3.rollup(v, vv => d3.sum(vv, d => d.amount), d => d.purpose);
const total = d3.sum(v, d => d.amount);
const mainPurpose = Array.from(byPurpose, ([purpose, amount]) => ({purpose, amount}))
.sort((a, b) => b.amount - a.total)[0];
return {total, mainPurpose: mainPurpose.purpose, purposes: byPurpose};
},
d => d.donor,
d => d.recipient
);
donorRecipientPurpose.forEach((recipients, donor) => {
recipients.forEach((data, recipient) => {
matrixData.push({
donor,
recipient,
total: data.total,
mainPurpose: data.mainPurpose,
purposes: data.purposes
});
});
});
const xScale = d3.scaleBand()
.domain(top10Recipients)
.range([0, width])
.padding(0.1);
const yScale = d3.scaleBand()
.domain(top20Donors)
.range([0, height])
.padding(0.1);
const sizeScale = d3.scaleSqrt()
.domain(d3.extent(matrixData, d => d.total))
.range([2, Math.min(xScale.bandwidth(), yScale.bandwidth()) / 2]);
const purposeColor = d3.scaleOrdinal(d3.schemeSet3).domain(top5Purposes);
g.selectAll(".matrix-cell")
.data(matrixData)
.enter().append("circle")
.attr("class", "matrix-cell")
.attr("cx", d => xScale(d.recipient) + xScale.bandwidth()/2)
.attr("cy", d => yScale(d.donor) + yScale.bandwidth()/2)
.attr("r", d => sizeScale(d.total))
.attr("fill", d => purposeColor(d.mainPurpose))
.attr("opacity", 0.7);
g.append("g")
.attr("transform", `translate(0, ${height})`)
.call(d3.axisBottom(xScale))
.selectAll("text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-45)");
g.append("g")
.call(d3.axisLeft(yScale));
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 0 - margin.left/2)
.attr("x", 0 - (height / 2))
.attr("dy", "1em")
.style("text-anchor", "middle")
.style("font-weight", "bold")
.text("Países Donantes");
svg.append("text")
.attr("transform", `translate(${width/2 + margin.left}, ${height + margin.top + 80})`)
.style("text-anchor", "middle")
.style("font-weight", "bold")
.text("Países Receptores");
return svg.node();
}
Insert cell
Insert cell
temporalEvolution = {
const margin = {top: 20, right: 100, bottom: 100, left: 50};
const width = 1200 - margin.left - margin.right;
const height = 600 - margin.bottom - margin.top;
const svg = d3.create("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.bottom + margin.top);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const filteredData = aiddata.filter(d =>
top20Donors.includes(d.donor) && top10Recipients.includes(d.recipient)
);
const yearlyFlows = d3.rollup(
filteredData,
v => d3.sum(v, d => d.amount),
d => d.yearInt,
d => d.donor,
d => d.recipient
);
const yearRange = d3.extent(filteredData, d => d.yearInt);
const xScale = d3.scaleLinear()
.domain(yearRange)
.range([0, width]);
const topPairs = d3.rollup(
filteredData,
v => d3.sum(v, d => d.amount),
d => `${d.donor}-${d.recipient}`
);
const top10Pairs = Array.from(topPairs, ([pair, total]) => ({pair, total}))
.sort((a, b) => b.total - a.total)
.slice(0, 10)
.map(d => d.pair);
const lineData = top10Pairs.map(pair => {
const [donor, recipient] = pair.split('-');
const yearData = [];
for (let year = yearRange[0]; year <= yearRange[1]; year++) {
const amount = yearlyFlows.get(year)?.get(donor)?.get(recipient) || 0;
yearData.push({year, amount, donor, recipient, pair});
}
return {pair, donor, recipient, data: yearData};
});
const maxAmount = d3.max(lineData, d => d3.max(d.data, dd => dd.amount));
const yScale = d3.scaleLinear()
.domain([0, maxAmount])
.range([height, 0]);
const colorScale = d3.scaleOrdinal(d3.schemeCategory10)
.domain(top10Pairs);
const line = d3.line()
.x(d => xScale(d.year))
.y(d => yScale(d.amount))
.curve(d3.curveMonotoneX);
lineData.forEach(d => {
g.append("path")
.datum(d.data)
.attr("fill", "none")
.attr("stroke", colorScale(d.pair))
.attr("stroke-width", 2)
.attr("opacity", 0.7)
.attr("d", line);
});
g.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(xScale).tickFormat(d3.format("d")));
g.append("g")
.call(d3.axisLeft(yScale).tickFormat(d => `$${d/1e9}B`));
svg.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 0 - margin.left)
.attr("x", 0 - (height / 2))
.attr("dy", "1em")
.style("text-anchor", "middle")
.text("Monto de Donación (USD)");
svg.append("text")
.attr("transform", `translate(${width/2 + margin.left}, ${height + margin.top + 50})`)
.style("text-anchor", "middle")
.text("Año");
const legend = g.append("g")
.attr("transform", `translate(${width - 200}, 20)`);
lineData.slice(0, 5).forEach((d, i) => {
const legendItem = legend.append("g")
.attr("transform", `translate(0, ${i * 20})`);
legendItem.append("line")
.attr("x1", 0)
.attr("x2", 15)
.attr("stroke", colorScale(d.pair))
.attr("stroke-width", 2);
legendItem.append("text")
.attr("x", 20)
.attr("dy", "0.35em")
.style("font-size", "10px")
.text(`${d.donor} → ${d.recipient}`);
});
return svg.node();
}
Insert cell
Insert cell
temporalFlowMap = {
const width = 1200;
const height = 700;
const controlHeight = 100;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height + controlHeight);
const mapG = svg.append("g");
const controlG = svg.append("g")
.attr("transform", `translate(0, ${height})`);
const projection = d3.geoNaturalEarth1()
.scale(150)
.translate([width / 2, height / 2]);
const path = d3.geoPath().projection(projection);
mapG.append("g")
.selectAll("path")
.data(geoJSON.features)
.enter().append("path")
.attr("d", path)
.attr("fill", "#f8f9fa")
.attr("stroke", "#dee2e6")
.attr("stroke-width", 0.5);
const filteredData = aiddata.filter(d =>
top20Donors.includes(d.donor) && top10Recipients.includes(d.recipient)
);
const yearRange = d3.extent(filteredData, d => d.yearInt);
const years = d3.range(yearRange[0], yearRange[1] + 1);
const yearlyData = d3.group(filteredData, d => d.yearInt);
const timeScale = d3.scaleLinear()
.domain(yearRange)
.range([50, width - 50]);
const timeline = controlG.append("g")
.attr("transform", "translate(0, 30)");
timeline.append("line")
.attr("x1", timeScale.range()[0])
.attr("x2", timeScale.range()[1])
.attr("y1", 20)
.attr("y2", 20)
.attr("stroke", "#6c757d")
.attr("stroke-width", 2);
timeline.selectAll(".year-tick")
.data(years.filter((d, i) => i % 3 === 0))
.enter().append("g")
.attr("class", "year-tick")
.attr("transform", d => `translate(${timeScale(d)}, 20)`)
.each(function(d) {
d3.select(this).append("circle")
.attr("r", 3)
.attr("fill", "#6c757d");
d3.select(this).append("text")
.attr("y", 15)
.attr("text-anchor", "middle")
.style("font-size", "10px")
.text(d);
});
const yearIndicator = timeline.append("g")
.attr("class", "year-indicator");
yearIndicator.append("circle")
.attr("r", 6)
.attr("fill", "#007bff")
.attr("cy", 20);
const yearLabel = yearIndicator.append("text")
.attr("y", -10)
.attr("text-anchor", "middle")
.style("font-weight", "bold")
.style("font-size", "14px")
.attr("fill", "#007bff");
const flowsG = mapG.append("g").attr("class", "flows");
const pointsG = mapG.append("g").attr("class", "points");
function updateMap(year) {
const currentData = yearlyData.get(year) || [];
const flows = d3.rollup(
currentData,
v => d3.sum(v, d => d.amount),
d => d.donor,
d => d.recipient
);
const arcData = [];
flows.forEach((recipients, donor) => {
const donorCoords = getCountryCoordinates(donor, geoJSON);
if (donorCoords) {
recipients.forEach((amount, recipient) => {
const recipientCoords = getCountryCoordinates(recipient, geoJSON);
if (recipientCoords && amount > 0) {
arcData.push({
source: donorCoords,
target: recipientCoords,
donor,
recipient,
amount
});
}
});
}
});
const maxAmount = d3.max(arcData, d => d.amount) || 1;
const strokeScale = d3.scaleLinear()
.domain([0, maxAmount])
.range([1, 10]);
function createArc(source, target) {
const sourceProj = projection(source);
const targetProj = projection(target);
if (!sourceProj || !targetProj) return null;
const dx = targetProj[0] - sourceProj[0];
const dy = targetProj[1] - sourceProj[1];
const dr = Math.sqrt(dx * dx + dy * dy) * 0.4;
return `M${sourceProj[0]},${sourceProj[1]}A${dr},${dr} 0 0,1 ${targetProj[0]},${targetProj[1]}`;
}
const arcs = flowsG.selectAll(".flow-arc")
.data(arcData, d => `${d.donor}-${d.recipient}`);
arcs.exit()
.transition()
.duration(500)
.attr("opacity", 0)
.remove();
arcs.enter().append("path")
.attr("class", "flow-arc")
.attr("d", d => createArc(d.source, d.target))
.attr("fill", "none")
.attr("stroke", "#ff6b35")
.attr("opacity", 0)
.transition()
.duration(500)
.attr("opacity", 0.6)
.attr("stroke-width", d => strokeScale(d.amount));
arcs.transition()
.duration(500)
.attr("stroke-width", d => strokeScale(d.amount))
.attr("opacity", 0.6);
const countryTotals = new Map();
arcData.forEach(d => {
countryTotals.set(d.donor, (countryTotals.get(d.donor) || 0) + d.amount);
countryTotals.set(d.recipient, (countryTotals.get(d.recipient) || 0) + d.amount);
});
const pointData = [];
countryTotals.forEach((total, country) => {
const coords = getCountryCoordinates(country, geoJSON);
if (coords) {
pointData.push({
country,
coords: projection(coords),
total,
isDonor: top20Donors.includes(country)
});
}
});
const maxTotal = d3.max(pointData, d => d.total) || 1;
const radiusScale = d3.scaleSqrt()
.domain([0, maxTotal])
.range([2, 12]);
const points = pointsG.selectAll(".country-point")
.data(pointData.filter(d => d.coords), d => d.country);
points.exit()
.transition()
.duration(500)
.attr("r", 0)
.remove();
points.enter().append("circle")
.attr("class", "country-point")
.attr("cx", d => d.coords[0])
.attr("cy", d => d.coords[1])
.attr("r", 0)
.attr("fill", d => d.isDonor ? "#2196f3" : "#ff9800")
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.transition()
.duration(500)
.attr("r", d => radiusScale(d.total));
points.transition()
.duration(500)
.attr("r", d => radiusScale(d.total));
yearIndicator.attr("transform", `translate(${timeScale(year)}, 0)`);
yearLabel.text(year);
}
const controls = controlG.append("g")
.attr("transform", "translate(50, 60)");
let currentYearIndex = 0;
let isPlaying = false;
let playInterval;
const playButton = controls.append("g")
.style("cursor", "pointer");
playButton.append("circle")
.attr("r", 20)
.attr("fill", "#007bff")
.attr("stroke", "#fff")
.attr("stroke-width", 2);
const playIcon = playButton.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.style("font-size", "16px")
.style("fill", "white")
.text("▶");
playButton.on("click", function() {
if (isPlaying) {
clearInterval(playInterval);
isPlaying = false;
playIcon.text("▶");
} else {
isPlaying = true;
playIcon.text("⏸");
playInterval = setInterval(() => {
currentYearIndex = (currentYearIndex + 1) % years.length;
updateMap(years[currentYearIndex]);
if (currentYearIndex === years.length - 1) {
setTimeout(() => {
clearInterval(playInterval);
isPlaying = false;
playIcon.text("▶");
}, 1000);
}
}, 800);
}
});
const slider = controls.append("g")
.attr("transform", "translate(60, 0)");
const sliderScale = d3.scaleLinear()
.domain([0, years.length - 1])
.range([0, 300]);
slider.append("line")
.attr("x1", 0)
.attr("x2", 300)
.attr("stroke", "#6c757d")
.attr("stroke-width", 4);
const sliderHandle = slider.append("circle")
.attr("r", 8)
.attr("fill", "#007bff")
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.style("cursor", "pointer")
.call(d3.drag()
.on("drag", function(event) {
const x = Math.max(0, Math.min(300, event.x));
const index = Math.round(sliderScale.invert(x));
currentYearIndex = index;
d3.select(this).attr("cx", sliderScale(index));
updateMap(years[index]);
})
);
updateMap(years[0]);
return svg.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