{
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();
}