Public
Edited
May 16
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
//projection = d3.geoMercator().fitSize([width, height], geoJSON);
Insert cell
//path = d3.geoPath().projection(projection);
Insert cell
Insert cell
margin = ({top: 20, right: 20, bottom: 20, left: 20})
Insert cell
width = 960
Insert cell
height = 600
Insert cell
Insert cell
corregirNombre = (nombre) => {
const equivalencias = new Map([
["United States", "United States of America"],
["Korea", "South Korea"],
["Slovak Republic", "Slovakia"],
["Czech Republic", "Czechia"],
["Russian Federation", "Russia"],
["Iran (Islamic Republic of)", "Iran"],
["Syrian Arab Republic", "Syria"],
["Lao People's Democratic Republic", "Laos"],
["Libyan Arab Jamahiriya", "Libya"],
["Cote d'Ivoire", "Ivory Coast"],
["Congo, Dem. Rep.", "Democratic Republic of the Congo"],
["Congo, Rep.", "Republic of the Congo"]
]);
return equivalencias.get(nombre) || nombre;
}
Insert cell
donadoPorPais = d3.rollup(
aiddata,
v => d3.sum(v, d => d.amount),
d => corregirNombre(d.donor)
)
Insert cell
recibidoPorPais = d3.rollup(
aiddata,
v => d3.sum(v, d => d.amount),
d => corregirNombre(d.recipient)
)
Insert cell
allCountries = new Set([
...Array.from(donadoPorPais.keys()),
...Array.from(recibidoPorPais.keys())
])
Insert cell
geoDataConBalance = {
const features = geoJSON.features.map(feature => {
const name = feature.properties.ADMIN;
const donado = donadoPorPais.get(name) || 0;
const recibido = recibidoPorPais.get(name) || 0;
const balance = balancePorPais.get(name);

let tipo = 'no data';
if (balance !== undefined) {
if (balance > 0) tipo = 'Net Receptor';
else if (balance < 0) tipo = 'Net Donor';
else tipo = 'Neutral';
}
return {
...feature,
properties: {
...feature.properties,
donado,
recibido,
balance,
tipo
}
};
});
return { ...geoJSON, features };
}
Insert cell
balancePorPais = new Map();
Insert cell
allCountries.forEach(country => {
const donado = donadoPorPais.get(country) || 0;
const recibido = recibidoPorPais.get(country) || 0;
balancePorPais.set(country, recibido - donado);
});
Insert cell
maxAbsBalance = d3.max(Array.from(balancePorPais.values()), d => Math.abs(d)) || 1;
Insert cell
color = d3.scaleDiverging()
.domain([-maxAbsBalance, 0, maxAbsBalance])
.interpolator(d3.interpolateRdBu);
Insert cell
projection = d3.geoNaturalEarth1()
.fitSize([width - 50, height - 50], geoJSON)
Insert cell
path = d3.geoPath().projection(projection)
Insert cell
{
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("display", "block")
.style("margin", "0 auto")
.style("background", "#f0f0f0");

const g = svg.append("g");

g.selectAll("path")
.data(geoDataConBalance.features)
.join("path")
.attr("d", path)
.attr("fill", d => {
return d.properties.balance === undefined ? "#ccc" : color(d.properties.balance);
})
.attr("stroke", "#333")
.attr("stroke-width", 0.5)
.append("title")
.text(d => {
const props = d.properties;
let text = `${props.NAME || props.ADMIN}\nType: ${props.tipo}`;
if (props.balance !== undefined) {
text += `\nBalance: ${d3.format("$,.0f")(props.balance)} USD`;
text += `\nDonated: ${d3.format("$,.0f")(props.donado)} USD`;
text += `\nReceived: ${d3.format("$,.0f")(props.recibido)} USD`;
}
return text;
});

// More sophisticated tooltip (like in the UTEC example)
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip")
.style("position", "absolute")
.style("background", "rgba(0,0,0,0.8)")
.style("color", "#fff")
.style("padding", "8px 12px")
.style("border-radius", "4px")
.style("font-size", "12px")
.style("pointer-events", "none")
.style("visibility", "hidden")
.style("line-height", "1.4");

g.selectAll("path")
.on("mouseover", (event, d) => {
tooltip.style("visibility", "visible");
const props = d.properties;
let html = `<strong>${props.NAME || props.ADMIN}</strong><br/>Type: ${props.tipo}`;
if (props.balance !== undefined) {
html += `<br/>Balance: ${d3.format("$,.0f")(props.balance)} USD`;
html += `<br/>Donated: ${d3.format("$,.0f")(props.donado)} USD`;
html += `<br/>Received: ${d3.format("$,.0f")(props.recibido)} USD`;
}
tooltip.html(html);
})
.on("mousemove", (event) => {
tooltip.style("top", (event.pageY - 10) + "px")
.style("left", (event.pageX + 10) + "px");
})
.on("mouseout", () => {
tooltip.style("visibility", "hidden");
});
const zoom = d3.zoom()
.scaleExtent([1, 8])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);

// Legend
const legendWidth = 300;
const legendHeight = 12;
const legendX = margin.left + 20;
const legendY = height - margin.bottom - legendHeight - 20;
const steps = 100;

const legendScale = d3.scaleLinear()
.domain([0, steps -1])
.range([0, legendWidth]);
const legendGroup = svg.append("g")
.attr("transform", `translate(${legendX}, ${legendY})`);

legendGroup.selectAll("rect.legend")
.data(d3.range(steps))
.enter()
.append("rect")
.attr("class", "legend")
.attr("x", (d, i) => legendScale(i))
.attr("y", 0)
.attr("width", legendWidth / steps)
.attr("height", legendHeight)
.attr("fill", d => color( (d / (steps - 1)) * 2 * maxAbsBalance - maxAbsBalance ));

legendGroup.append("text")
.attr("x", 0)
.attr("y", legendHeight + 14)
.style("font-size", "10px")
.style("text-anchor", "start")
.text(`Net Donor (${d3.format("$.2s")(-maxAbsBalance)})`);

legendGroup.append("text")
.attr("x", legendWidth / 2)
.attr("y", legendHeight + 14)
.style("font-size", "10px")
.style("text-anchor", "middle")
.text("Neutral ($0)");

legendGroup.append("text")
.attr("x", legendWidth)
.attr("y", legendHeight + 14)
.style("font-size", "10px")
.style("text-anchor", "end")
.text(`Net Recipient (${d3.format("$.2s")(maxAbsBalance)})`);
legendGroup.append("text")
.attr("x", legendWidth / 2)
.attr("y", -5) // Above legend bar
.style("font-size", "12px")
.style("text-anchor", "middle")
.style("font-weight", "bold")
.text("Aid Balance");

return svg.node();
}
Insert cell
topPurposesData = {
const purposeCounts = d3.rollup(aiddata, v => v.length, d => d.purpose);
const sortedPurposes = Array.from(purposeCounts.entries()).sort((a, b) => b[1] - a[1]);
const top5Purposes = sortedPurposes.slice(0, 5).map(d => d[0]);

const aidForTop5Purposes = aiddata.filter(d => top5Purposes.includes(d.purpose));
const aggregatedDataForTopPurposes = d3.rollup(
aidForTop5Purposes,
v => d3.sum(v, d => d.amount),
d => corregirNombre(d.recipient),
d => d.purpose
);
return {
top5Purposes,
aggregatedDataForTopPurposes
};
}
Insert cell
viewof selectedPurpose = Inputs.select(topPurposesData.top5Purposes, {label: "Select Aid Purpose"})
Insert cell
{
if (!selectedPurpose) return md`Select a purpose to see its distribution.`;

const aidForPurpose = aiddata.filter(d => d.purpose === selectedPurpose);

const receivedForPurposeByCountry = d3.rollup(
aidForPurpose,
v => d3.sum(v, d => d.amount),
d => corregirNombre(d.recipient)
);
const maxReceivedForPurpose = d3.max(Array.from(receivedForPurposeByCountry.values())) || 1;

const purposeColor = d3.scaleSequentialLog(d3.interpolateGreens)
.domain([1, maxReceivedForPurpose])
.clamp(true);


const geoDataForPurpose = (() => {
const features = geoJSON.features.map(feature => {
const name = feature.properties.ADMIN;
const receivedAmount = receivedForPurposeByCountry.get(name) || 0;
const totalDonatedByCountry = donadoPorPais.get(name) || 0;
const totalReceivedByCountry = recibidoPorPais.get(name) || 0;
return {
...feature,
properties: {
...feature.properties,
receivedAmountForPurpose: receivedAmount,
totalDonatedOverall: totalDonatedByCountry,
totalReceivedOverall: totalReceivedByCountry,
}
};
});
return { ...geoJSON, features };
})();

const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("display", "block")
.style("margin", "0 auto")
.style("background", "#f0f0f0");

const g = svg.append("g");
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip-purpose") // Usar una clase diferente si quieres estilos distintos
.style("position", "absolute")
.style("background", "rgba(0,0,0,0.8)")
.style("color", "#fff")
.style("padding", "8px 12px")
.style("border-radius", "4px")
.style("font-size", "12px")
.style("pointer-events", "none")
.style("visibility", "hidden")
.style("line-height", "1.4");
g.selectAll("path")
.data(geoDataForPurpose.features)
.join("path")
.attr("d", path)
.attr("fill", d => {
const amount = d.properties.receivedAmountForPurpose;
return amount > 0 ? purposeColor(amount) : "#ccc";
})
.attr("stroke", "#333")
.attr("stroke-width", 0.5)
// .append("title") // Ya no usaremos el tooltip simple del navegador
// *** MODIFICACIÓN: Añadir eventos para el tooltip HTML ***
.on("mouseover", (event, d) => {
tooltip.style("visibility", "visible");
const props = d.properties;
let html = `<strong>${props.NAME || props.ADMIN}</strong>`;
html += `<br/>Purpose: ${selectedPurpose}`;
html += `<br/>Received for this purpose: ${d3.format("$,.0f")(props.receivedAmountForPurpose)} USD`;
html += `<hr style="margin: 4px 0; border-color: #555;">`; // Separador
html += `Overall Donated: ${d3.format("$,.0f")(props.totalDonatedOverall)} USD`;
html += `<br/>Overall Received: ${d3.format("$,.0f")(props.totalReceivedOverall)} USD`;
tooltip.html(html);
})
.on("mousemove", (event) => {
tooltip.style("top", (event.pageY - 10) + "px")
.style("left", (event.pageX + 10) + "px");
})
.on("mouseout", () => {
tooltip.style("visibility", "hidden");
});
const zoom = d3.zoom()
.scaleExtent([1, 8])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);

const legendPurposeWidth = 200;
const legendHeightLocal = 12;
const legendPurposeX = margin.left + 20;
const legendPurposeY = height - margin.bottom - legendHeightLocal - 20;
const stepsLocal = 100;

const legendPurposeGroup = svg.append("g")
.attr("transform", `translate(${legendPurposeX}, ${legendPurposeY})`);
legendPurposeGroup.selectAll("rect.legend-purpose")
.data(d3.range(stepsLocal))
.enter()
.append("rect")
.attr("class", "legend-purpose")
.attr("x", (d, i) => (i / stepsLocal) * legendPurposeWidth) // Position each rect segment
.attr("y", 0)
.attr("width", legendPurposeWidth / stepsLocal) // Width of each segment
.attr("height", legendHeightLocal)
.attr("fill", d_idx => {
// Map index (0 to stepsLocal-1) to a value in the log scale domain [1, maxReceivedForPurpose]
const t = d_idx / (stepsLocal - 1); // Normalized position (0 to 1)
// To sample from a log scale linearly, interpolate in the log domain then exponentiate
if (maxReceivedForPurpose <= 1) return purposeColor(1); // Handle edge case if max is 1 or less
const value = Math.exp(Math.log(1) * (1-t) + Math.log(maxReceivedForPurpose) * t);
return purposeColor(value);
});
legendPurposeGroup.append("text")
.attr("x", 0)
.attr("y", legendHeightLocal + 14)
.style("font-size", "10px")
.style("text-anchor", "start")
.text(d3.format("$.0s")(1));

legendPurposeGroup.append("text")
.attr("x", legendPurposeWidth)
.attr("y", legendHeightLocal + 14)
.style("font-size", "10px")
.style("text-anchor", "end")
.text(d3.format("$.0s")(maxReceivedForPurpose));
legendPurposeGroup.append("text")
.attr("x", legendPurposeWidth / 2)
.attr("y", -5)
.style("font-size", "12px")
.style("text-anchor", "middle")
.style("font-weight", "bold")
.text(`Amount Received for ${selectedPurpose}`);


return svg.node();
}
Insert cell
Insert cell
Insert cell
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