Public
Edited
May 22
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart = {
const width = 928;
const height = width;
// Extract the data components, including categoryFilter and categoryFilteredValues
const {names, linkValues, categoryFilteredValues, categorySpecificValues, countryToContinent, categoryFilter, categoryPairsMap} = data;
const outerRadius = Math.min(width, height) * 0.5 - 70;
const innerRadius = outerRadius - 10;
const tickStep = d3.tickStep(0, d3.sum(data.flat()), 100);
const formatValue = d3.format(".1~%");
// Dark mode configuration
const isDarkMode = darkMode === "dark";
const backgroundColor = isDarkMode ? "#1a1a1a" : "#ffffff";
const textColor = isDarkMode ? "#ffffff" : "#000000";
const fadedTextColor = isDarkMode ? "#cccccc" : "#666666";
// Pre-sort data and names for consistent ordering
const volumeByName = {};
names.forEach((name, i) => {
volumeByName[name] = d3.sum(data[i]);
});
// Create sorted order: largest first, "Other" last
const sortedNames = [...names].sort((a, b) => {
if (a === "Other") return 1;
if (b === "Other") return -1;
return volumeByName[b] - volumeByName[a];
});
// Create reordered matrix
const nameToOldIndex = {};
names.forEach((name, i) => nameToOldIndex[name] = i);
const reorderedMatrix = sortedNames.map(sourceName =>
sortedNames.map(targetName =>
data[nameToOldIndex[sourceName]][nameToOldIndex[targetName]]
)
);
const chord = d3.chord()
.padAngle(10 / innerRadius)
.sortSubgroups(d3.descending)
.sortChords(d3.descending);
const arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
const ribbon = d3.ribbon()
.radius(innerRadius - 1)
.padAngle(1 / innerRadius);
// Use the dynamic color scale based on selection
const color = getColorScale(colorScheme, sortedNames, reorderedMatrix, countryToContinent);
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("style", `width: 100%; height: auto; font: 10px sans-serif; background-color: ${backgroundColor};`);
// Add CSS styles for hover effects (with dark mode support)
svg.append("style").text(`
.chord-arc {
opacity: 1;
}
.chord-arc.fade {
opacity: 0.2;
}
.chord-arc:hover {
opacity: 0.9;
}
.chord-ribbon {
opacity: 0.8;
mix-blend-mode: ${isDarkMode ? 'lighten' : 'multiply'};
}
.chord-ribbon.fade {
opacity: 0.1;
}
.chord-ribbon.filtered {
display: none;
}
text {
fill: ${textColor};
font-weight: bold;
}
.tick-text {
fill: ${fadedTextColor};
font-weight: normal;
}
`);
const chords = chord(reorderedMatrix);
const group = svg.append("g")
.selectAll("g")
.data(chords.groups)
.join("g");
group.append("path")
.attr("class", "chord-arc")
.attr("fill", d => color(sortedNames[d.index]))
.attr("d", arc)
.attr("id", d => `arc-${d.index}`)
.on("mouseenter", function(event, d) {
// Fade all chords and arcs
svg.selectAll(".chord-ribbon, .chord-arc")
.classed("fade", true);
// Build array of connected indices
const connectedIndices = new Set();
// First identify all direct connections through ribbons
svg.selectAll(`.chord-ribbon.source-${d.index}, .chord-ribbon.target-${d.index}`)
.each(function(ribbonData) {
// Only consider visible (not filtered) ribbons
if (!d3.select(this).classed("filtered")) {
// Only add the connected index (not this arc's index)
if (ribbonData.source.index === d.index) {
connectedIndices.add(ribbonData.target.index);
} else {
connectedIndices.add(ribbonData.source.index);
}
}
})
.classed("fade", false);
// Add the current arc index
connectedIndices.add(d.index);
// Unfade only directly connected arcs and current arc
connectedIndices.forEach(index => {
svg.select(`#arc-${index}`).classed("fade", false);
});
})
.on("mouseleave", function() {
// Reset all elements to normal state
svg.selectAll(".chord-ribbon, .chord-arc")
.classed("fade", false);
});
group.append("title")
.text(d => {
// Format raw value in dollars
const rawValue = data.volumeByItem ? data.volumeByItem[sortedNames[d.index]] : 0;
const dollarValue = formatBigNumber(rawValue);
return `${sortedNames[d.index]}\n$${dollarValue}`;
});
const groupTick = group.append("g")
.selectAll("g")
.data(d => groupTicks(d, tickStep))
.join("g")
.attr("transform", d => `rotate(${d.angle * 180 / Math.PI - 90}) translate(${outerRadius},0)`);
groupTick.append("text")
.attr("class", "tick-text")
.attr("x", 8)
.attr("dy", "0.35em")
.attr("transform", d => d.angle > Math.PI ? "rotate(180) translate(-16)" : null)
.attr("text-anchor", d => d.angle > Math.PI ? "end" : null);
group.select("text")
.attr("font-weight", "bold")
.text(function(d) {
return this.getAttribute("text-anchor") === "end"
? `↑ ${sortedNames[d.index]}`
: `${sortedNames[d.index]} ↓`;
});
const ribbons = svg.append("g")
.attr("fill-opacity", 0.8)
.selectAll("path")
.data(chords)
.join("path")
.attr("class", d => `chord-ribbon source-${d.source.index} target-${d.target.index}`)
.attr("fill", d => color(sortedNames[d.source.index]))
.attr("d", ribbon);
// Apply category filtering to ribbons if needed
if (categoryFilter && categoryFilter !== "all") {
ribbons.each(function(d) {
const sourceName = sortedNames[d.source.index];
const targetName = sortedNames[d.target.index];
// Check if this ribbon is related to the selected category
const sourceToTargetKey = `${sourceName}_${targetName}`;
const targetToSourceKey = `${targetName}_${sourceName}`;
// If filtered values for this pair don't exist, hide the ribbon
if (!categoryFilteredValues[sourceToTargetKey] && !categoryFilteredValues[targetToSourceKey]) {
d3.select(this).classed("filtered", true);
}
});
}
ribbons.on("mouseenter", function(event, d) {
// Fade all chords and arcs
svg.selectAll(".chord-ribbon, .chord-arc")
.classed("fade", true);
// Keep only this chord visible
d3.select(this)
.classed("fade", false);
// Keep only connected arcs visible
const sourceArc = svg.select(`#arc-${d.source.index}`);
const targetArc = svg.select(`#arc-${d.target.index}`);
sourceArc.classed("fade", false);
targetArc.classed("fade", false);
})
.on("mouseleave", function() {
// Reset all elements to normal state
svg.selectAll(".chord-ribbon, .chord-arc")
.classed("fade", false);
});
ribbons.append("title")
.text(function(d){
// Get the names of source and target
let sourceName = sortedNames[d.source.index];
let targetName = sortedNames[d.target.index];
// If the ribbon is filtered (hidden), don't show any tooltip
if (d3.select(this).classed("filtered")) {
return "";
}
// Create keys for both directions
const sourceToTargetKey = `${sourceName}_${targetName}`;
const targetToSourceKey = `${targetName}_${sourceName}`;
// CHANGED: Handle source-target values based on category filter
let sourceToTargetValue, targetToSourceValue;
let lineTitle = '';
if (categoryFilter !== "all") {
// Try to get category-specific values from categories CSV
const sourceOrigKey = `${sourceName}_${targetName}`;
const targetOrigKey = `${targetName}_${sourceName}`;
sourceToTargetValue = categorySpecificValues[sourceOrigKey];
targetToSourceValue = categorySpecificValues[targetOrigKey];
} else {
// Use total values for "All Categories"
sourceToTargetValue = categoryFilteredValues[sourceToTargetKey];
targetToSourceValue = categoryFilteredValues[targetToSourceKey];
}
// Build tooltip text
if (sourceToTargetValue) {
let fromSourceToTargetValue = formatBigNumber(sourceToTargetValue);
lineTitle += `${fromSourceToTargetValue} ${sourceName} → ${targetName}`;
}
if (targetToSourceValue && d.source.index !== d.target.index) {
let fromTargetToSourceValue = formatBigNumber(targetToSourceValue);
lineTitle += `\n${fromTargetToSourceValue} ${targetName} → ${sourceName}`;
}
return lineTitle;
});
return svg.node();
}
Insert cell
async function processData(dataType, viewType, countryLimit = 10, categoryFilter = "all", yearFilter = "all") {
let tradeData, categoriesData, countriesData;
if (dataType === "exports") {
tradeData = await FileAttachment("exports.csv").csv();
} else {
tradeData = await FileAttachment("imports.csv").csv();
}
countriesData = await FileAttachment("countries.csv").csv();
const sourceColumn = "from";
const targetColumn = "to";
const volumeColumn = "value";
let items = [];
let volumeByItem = {};
let countryToContinent = {};
countriesData.forEach(d => {
countryToContinent[d.Country] = d.Continent;
});

// Apply year filter first if selected
let yearFilteredData = tradeData;
if (yearFilter !== "all") {
yearFilteredData = tradeData.filter(d => d.year && d.year.toString() === yearFilter);
}

// Apply category filter if selected
let filteredTradeData = yearFilteredData;
let categoryPairsMap = {};
let categoryFilteredValues = {};
let categorySpecificValues = {}; // For storing actual category-specific values

if (categoryFilter !== "all") {
// We need category data to find related country pairs
if (!categoriesData) {
if (dataType === "exports") {
const categoriesData1 = await FileAttachment("export_categories.csv").csv();
const categoriesData2 = await FileAttachment("export_categories@1.csv").csv();
categoriesData = [...categoriesData1, ...categoriesData2];
} else {
const categoriesData1 = await FileAttachment("import_categories.csv").csv();
const categoriesData2 = await FileAttachment("import_categories@1.csv").csv();
categoriesData = [...categoriesData1, ...categoriesData2];
}
}
// Also apply year filter to categories data
let yearFilteredCategoriesData = categoriesData;
if (yearFilter !== "all") {
yearFilteredCategoriesData = categoriesData.filter(d => d.year && d.year.toString() === yearFilter);
}
// Filter trade data to only include entries related to the selected category
const categoryFilteredData = yearFilteredCategoriesData.filter(d => d.category === categoryFilter);

const relatedPairs = {};
// Extract and store category-specific values
categoryFilteredData.forEach(d => {
const source = d[sourceColumn];
const target = d[targetColumn];
const volume = parseVolume(d[volumeColumn]);
const key = `${source}_${target}`;
relatedPairs[key] = true;
categoryPairsMap[key] = true;
// Store the actual category-specific value for tooltips
categorySpecificValues[key] = (categorySpecificValues[key] || 0) + volume;
});
filteredTradeData = categoryFilteredData.filter(d => {
const key = `${d[sourceColumn]}_${d[targetColumn]}`;
return relatedPairs[key];
});
} else {
filteredTradeData = yearFilteredData;
}
if (viewType === "countries") {
// Regular logic - find volume for all countries
// Use the year-filtered data source based on whether we're filtering
(categoryFilter !== "all" ? filteredTradeData : yearFilteredData).forEach(d => {
const source = d[sourceColumn];
const volume = parseVolume(d[volumeColumn]);
volumeByItem[source] = (volumeByItem[source] || 0) + volume;
});
// Sort countries by volume in descending order
const sortedEntries = Object.entries(volumeByItem)
.sort((a, b) => b[1] - a[1]);
// Take top N countries
items = sortedEntries
.slice(0, parseInt(countryLimit))
.map(d => d[0]);
// If showing less than all countries, add "Other"
if (parseInt(countryLimit) < sortedEntries.length && countryLimit !== "197") {
// Calculate total volume for other countries
let othersVolume = 0;
for (let i = parseInt(countryLimit); i < sortedEntries.length; i++) {
othersVolume += sortedEntries[i][1];
}
// Add "Other" item
items.push("Other");
volumeByItem["Other"] = othersVolume;
}
} else if (viewType === "regions") {
// Use the year-filtered data source based on whether we're filtering
(categoryFilter !== "all" ? filteredTradeData : yearFilteredData).forEach(d => {
const source = d[sourceColumn];
const continent = countryToContinent[source] || "Unknown";
const volume = parseVolume(d[volumeColumn]);
volumeByItem[continent] = (volumeByItem[continent] || 0) + volume;
});
items = Object.entries(volumeByItem)
.sort((a, b) => b[1] - a[1])
.map(d => d[0]);
}
const n = items.length;
const matrix = Array(n).fill().map(() => Array(n).fill(0));
const linkValues = {};
if (viewType === "countries") {
// We need to handle the "Other" category when building the matrix
const otherIdx = items.indexOf("Other");
// Use year-filtered data to build the matrix
yearFilteredData.forEach(d => {
const source = d[sourceColumn];
const target = d[targetColumn];
let sourceIdx = items.indexOf(source);
let targetIdx = items.indexOf(target);
// Check if source country is in "Other" category
if (sourceIdx === -1 && otherIdx !== -1) {
sourceIdx = otherIdx;
}
// Check if target country is in "Other" category
if (targetIdx === -1 && otherIdx !== -1) {
targetIdx = otherIdx;
}
if (sourceIdx !== -1 && targetIdx !== -1) {
const volume = parseVolume(d[volumeColumn]);
const maxValue = 1e10;

// We need to accumulate for "Other_Other" case, not replace
const sourceKey = sourceIdx === otherIdx ? "Other" : source;
const targetKey = targetIdx === otherIdx ? "Other" : target;
const linkKey = `${sourceKey}_${targetKey}`;
// Always add to linkValues for complete matrix structure
linkValues[linkKey] = (linkValues[linkKey] || 0) + volume;
// For filtered values in tooltips
if (categoryFilter === "all") {
// If not filtering, use the same values
categoryFilteredValues[linkKey] = linkValues[linkKey];
} else if (categoryPairsMap[`${source}_${target}`]) {
// If filtering, only include values for the selected category
categoryFilteredValues[linkKey] = (categoryFilteredValues[linkKey] || 0) + volume;
}
// Always add to matrix for proper chart structure
matrix[sourceIdx][targetIdx] += volume / maxValue;
}
});
} else if (viewType === "regions") {
yearFilteredData.forEach(d => {
const source = countryToContinent[d[sourceColumn]] || "Unknown";
const target = countryToContinent[d[targetColumn]] || "Unknown";
const sourceIdx = items.indexOf(source);
const targetIdx = items.indexOf(target);
if (sourceIdx !== -1 && targetIdx !== -1) {
const volume = parseVolume(d[volumeColumn]);
const maxValue = 1e10;
// Always add to linkValues for complete structure
const regionLinkKey = `${source}_${target}`;
linkValues[regionLinkKey] = (linkValues[regionLinkKey] || 0) + volume;
// For filtered values in tooltips
if (categoryFilter === "all") {
// If not filtering, use the same values
categoryFilteredValues[regionLinkKey] = linkValues[regionLinkKey];
} else if (categoryPairsMap[`${d[sourceColumn]}_${d[targetColumn]}`]) {
// If filtering, only include values for the selected category
categoryFilteredValues[regionLinkKey] = (categoryFilteredValues[regionLinkKey] || 0) + volume;
}
// Always add to matrix for proper chart structure
matrix[sourceIdx][targetIdx] += volume / maxValue;
}
});
}

const formattedData = Object.assign(matrix, {
names: items,
linkValues: linkValues,
categoryFilteredValues: categoryFilteredValues,
categorySpecificValues: categorySpecificValues,
countryToContinent: countryToContinent,
categoryFilter: categoryFilter,
categoryPairsMap: categoryPairsMap,
volumeByItem: volumeByItem
});
return formattedData;
}
Insert cell
data = processData(dataType, viewType, countryLimit, categoryFilter, selectedYear)
Insert cell
function groupTicks(d, step) {
const k = (d.endAngle - d.startAngle) / d.value;
return d3.range(0, d.value, step).map(value => {
return {value: value, angle: value * k + d.startAngle};
});
}
Insert cell
// Function for parsing volume
function parseVolume(volStr) {
if (!volStr) return 0;
volStr = volStr.replace('$', '');
let factor = 1;
if (volStr.endsWith('B')) {
factor = 1e9;
volStr = volStr.replace('B', '');
} else if (volStr.endsWith('M')) {
factor = 1e6;
volStr = volStr.replace('M', '');
} else if (volStr.endsWith('K')) {
factor = 1e3;
volStr = volStr.replace('K', '');
}
return parseFloat(volStr) * factor;
}
Insert cell
//convert big number to readable format
function formatBigNumber(num) {
if (num >= 1e12) return (num / 1e12).toFixed(1) + 'T';
if (num >= 1e9) return (num / 1e9).toFixed(1) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(1) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(1) + 'k';
return num.toString();
}
Insert cell
async function loadCategories(dataType) {
let categoriesData, countriesData;
try {
// Load the countries list
countriesData = await FileAttachment("countries.csv").csv();
const countryNames = countriesData.map(d => d.Country);
// Load the categories data
if (dataType === "exports") {
const categoriesData1 = await FileAttachment("export_categories.csv").csv();
const categoriesData2 = await FileAttachment("export_categories@1.csv").csv();
categoriesData = [...categoriesData1, ...categoriesData2];
} else {
const categoriesData1 = await FileAttachment("import_categories.csv").csv();
const categoriesData2 = await FileAttachment("import_categories@1.csv").csv();
categoriesData = [...categoriesData1, ...categoriesData2];
}
// Extract unique categories
const allCategoryValues = [...new Set(categoriesData.map(d => d.category))];
// Exclude countries
const uniqueCategories = allCategoryValues.filter(category => {
return category && !countryNames.includes(category);
});
// Sort
return uniqueCategories.sort();
} catch (error) {
console.error("Error fetching categories:", error);
return [];
}
}
Insert cell
function getColorScale(schemeType, names, data, countryToContinent) {
switch(schemeType) {
case "region":
// Region-based coloring - assign same color to countries from same continent
const regionColors = {
"America": "#1f77b4",
"Europe": "#ff7f0e",
"Asia": "#2ca02c",
"Africa": "#d62728",
"Australia": "#9467bd",
"Unknown": "#999999"
};
return name => {
if (name === "Other") return "#999999";
// Check if this is a country or a category
if (countryToContinent[name]) {
// This is a country
const continent = countryToContinent[name];
return regionColors[continent] || "#999999";
} else {
// This is likely a category or unknown
// Use a different color scheme for categories
return d3.schemeCategory10[names.indexOf(name) % 10];
}
};
case "categorical":
// Categorical color scheme - assign colors from categorical scheme
// Using d3.schemeTableau10 as an example, can be changed to other schemes
const categoricalScale = d3.scaleOrdinal(d3.schemeTableau10);
// Sort countries by value for consistent coloring
const valueByItem = {};
names.forEach((name, i) => {
valueByItem[name] = d3.sum(data[i]);
});
const sortedNames = [...names].sort((a, b) => valueByItem[b] - valueByItem[a]);
sortedNames.forEach((name, i) => {
if (name !== "Other") categoricalScale(name);
});
return name => {
if (name === "Other") return "#999999";
return categoricalScale(name);
};
case "linear":
default:
// Linear color scheme (current implementation)
return d3.scaleOrdinal(names.map(name => {
if (name === "Other") return "#999999";
return d3.quantize(d3.interpolateRainbow, names.length - (names.includes("Other") ? 1 : 0))[names.indexOf(name)];
}));
}
}
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