{
const continentMapping = {
'USA': 'North America', 'CAN': 'North America', 'MEX': 'North America',
'GTM': 'North America', 'HND': 'North America', 'SLV': 'North America',
'CRI': 'North America', 'PAN': 'North America', 'CUB': 'North America',
'DOM': 'North America', 'HTI': 'North America', 'JAM': 'North America',
'TTO': 'North America',
'BRA': 'South America', 'ARG': 'South America', 'COL': 'South America',
'CHL': 'South America', 'PER': 'South America', 'VEN': 'South America',
'ECU': 'South America', 'BOL': 'South America', 'PRY': 'South America',
'URY': 'South America', 'GUY': 'South America', 'SUR': 'South America',
'GBR': 'Europe', 'FRA': 'Europe', 'DEU': 'Europe', 'ITA': 'Europe',
'ESP': 'Europe', 'NOR': 'Europe', 'SWE': 'Europe', 'FIN': 'Europe',
'DNK': 'Europe', 'ISL': 'Europe', 'CHE': 'Europe', 'AUT': 'Europe',
'BEL': 'Europe', 'NLD': 'Europe', 'LUX': 'Europe', 'PRT': 'Europe',
'GRC': 'Europe', 'POL': 'Europe', 'CZE': 'Europe', 'SVK': 'Europe',
'HUN': 'Europe', 'ROU': 'Europe', 'BGR': 'Europe', 'SRB': 'Europe',
'HRV': 'Europe', 'SVN': 'Europe', 'ALB': 'Europe', 'MKD': 'Europe',
'BIH': 'Europe', 'MNE': 'Europe', 'KOS': 'Europe', 'UKR': 'Europe',
'BLR': 'Europe', 'MDA': 'Europe',
'RUS': 'Asia', 'KAZ': 'Asia', 'UZB': 'Asia', 'TKM': 'Asia',
'KGZ': 'Asia', 'TJK': 'Asia', 'GEO': 'Asia', 'ARM': 'Asia',
'AZE': 'Asia', 'TUR': 'Asia', 'IRN': 'Asia', 'IRQ': 'Asia',
'SAU': 'Asia', 'YEM': 'Asia', 'SYR': 'Asia', 'JOR': 'Asia',
'ARE': 'Asia', 'QAT': 'Asia', 'KWT': 'Asia', 'BHR': 'Asia',
'OMN': 'Asia', 'ISR': 'Asia', 'LBN': 'Asia', 'PSE': 'Asia',
'CHN': 'Asia', 'JPN': 'Asia', 'IND': 'Asia', 'IDN': 'Asia',
'KOR': 'Asia', 'PAK': 'Asia', 'BGD': 'Asia', 'PHL': 'Asia',
'VNM': 'Asia', 'THA': 'Asia', 'MYS': 'Asia', 'SGP': 'Asia',
'MMR': 'Asia', 'NPL': 'Asia', 'LKA': 'Asia', 'KHM': 'Asia',
'LAO': 'Asia', 'MNG': 'Asia', 'BTN': 'Asia', 'MDV': 'Asia',
'ZAF': 'Africa', 'EGY': 'Africa', 'NGA': 'Africa', 'KEN': 'Africa',
'ETH': 'Africa', 'TZA': 'Africa', 'DZA': 'Africa', 'MAR': 'Africa',
'LBY': 'Africa', 'TUN': 'Africa', 'GHA': 'Africa', 'ZWE': 'Africa',
'ZMB': 'Africa', 'AGO': 'Africa', 'MOZ': 'Africa', 'UGA': 'Africa',
'CMR': 'Africa', 'CIV': 'Africa', 'SDN': 'Africa', 'SSD': 'Africa',
'SEN': 'Africa', 'MLI': 'Africa', 'BFA': 'Africa', 'NER': 'Africa',
'TCD': 'Africa', 'SOM': 'Africa', 'RWA': 'Africa', 'BDI': 'Africa',
'BEN': 'Africa', 'TGO': 'Africa', 'MWI': 'Africa', 'ERI': 'Africa',
'GAB': 'Africa', 'GNQ': 'Africa', 'GIN': 'Africa', 'SLE': 'Africa',
'LBR': 'Africa', 'COG': 'Africa', 'COD': 'Africa', 'DJI': 'Africa',
'COM': 'Africa', 'CPV': 'Africa', 'STP': 'Africa', 'MDG': 'Africa',
'MUS': 'Africa', 'SYC': 'Africa',
'AUS': 'Oceania', 'NZL': 'Oceania', 'PNG': 'Oceania', 'FJI': 'Oceania'
};
const dataWithContinent = tradedata.map(d => ({
...d,
continent: continentMapping[d.country] || 'Unknown'
}));
const years = [...new Set(dataWithContinent.map(d => d.year))].sort();
const continentsList = ['All', ...[...new Set(dataWithContinent.map(d => d.continent))].filter(d => d !== 'Unknown')];
const getEducationCategory = y => y < 6 ? 'Low' : y < 10 ? 'Medium' : 'High';
const getAgeCategory = a => a < 25 ? 'Young' : a < 40 ? 'Middle-aged' : 'Older';
const processedByYearContinent = {};
years.forEach(year => {
processedByYearContinent[year] = {};
continentsList.forEach(cont => {
if (cont === 'All') return;
const rows = dataWithContinent.filter(r => r.year === year && r.continent === cont);
if (!rows.length) return;
const byIncome = d3.group(rows, r => r.income_class);
processedByYearContinent[year][cont] = Array.from(byIncome, ([cls, arr]) => ({
name: cls,
value: d3.mean(arr, d => d.food_insecurity_rate),
avgEducation: d3.mean(arr, d => d.education_avg_yrs),
avgAge: d3.mean(arr, d => d.median_age),
countryCount: arr.length,
countries: arr.map(d => d.country).join(', '),
continent: cont
}));
});
const merged = {};
Object.values(processedByYearContinent[year]).forEach(list => {
list.forEach(d => {
if (!merged[d.name]) merged[d.name] = { ...d };
else {
const c = merged[d.name].countryCount + d.countryCount;
merged[d.name].value = (merged[d.name].value * merged[d.name].countryCount + d.value * d.countryCount) / c;
merged[d.name].avgEducation = (merged[d.name].avgEducation * merged[d.name].countryCount + d.avgEducation * d.countryCount) / c;
merged[d.name].avgAge = (merged[d.name].avgAge * merged[d.name].countryCount + d.avgAge * d.countryCount) / c;
merged[d.name].countries += `, ${d.countries}`;
merged[d.name].countryCount = c;
}
});
});
processedByYearContinent[year]['All'] = Object.values(merged);
});
// Main container
const container = html`<div style="display:flex;flex-direction:column;align-items:center;font-family:sans-serif;width:100%;"></div>`;
// Title
const titleDiv = html`<div style="text-align:center;margin-bottom:20px;">
<h2 style="margin-bottom:5px;">Food Insecurity Distribution</h2>
<div class="subtitle-container" style="font-size:14px;color:#555;"></div>
</div>`;
container.appendChild(titleDiv);
// Store subtitle container reference
const subtitleContainer = titleDiv.querySelector('.subtitle-container');
// Controls row
const controlsRow = html`<div style="display:flex;justify-content:center;gap:30px;margin-bottom:20px;"></div>`;
container.appendChild(controlsRow);
// Year control
const yearControl = html`<div>
<label style="font-weight:bold;">Year:</label>
<select id="yearSelect" style="padding:4px;border-radius:4px;margin-left:5px;"></select>
</div>`;
controlsRow.appendChild(yearControl);
const yearSelect = yearControl.querySelector('#yearSelect');
years.forEach(y => {
yearSelect.appendChild(html`<option value="${y}" ${y===2020?'selected':''}>${y}</option>`);
});
// Continent control
const continentControl = html`<div>
<label style="font-weight:bold;">Continent:</label>
<select id="contSelect" style="padding:4px;border-radius:4px;margin-left:5px;"></select>
</div>`;
controlsRow.appendChild(continentControl);
const contSelect = continentControl.querySelector('#contSelect');
continentsList.forEach(c => contSelect.appendChild(html`<option value="${c}">${c}</option>`));
// Chart container
const chartContainer = html`<div style="width:700px;height:700px;position:relative;"></div>`;
container.appendChild(chartContainer);
// Create SVG
const width = 700;
const height = 700;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.style('max-width', '100%')
.style('height', 'auto');
chartContainer.appendChild(svg.node());
// Add defs for patterns
const defs = svg.append('defs');
// Color palette inspired by your image
const colorMapping = {
'Low': '#5F4B8B', // Deep Purple
'Lower Middle': '#E69873', // Peach/Orange
'Upper Middle': '#76A5AF', // Teal
'High': '#815839' // Brown
};
// Additional colors for patterns
const extendedColors = [
'#9B7ED9', // Light Purple
'#D473A2', // Pink
'#2E0854', // Dark Purple
'#998476', // Gray-Brown
'#4D7B85', // Dark Teal
'#EE964B' // Orange
];
// Function to create a striped pattern
function createStripedPattern(id, color, orientation = 'horizontal') {
const pattern = defs.append('pattern')
.attr('id', id)
.attr('patternUnits', 'userSpaceOnUse')
.attr('width', 8)
.attr('height', 8);
if (orientation === 'horizontal') {
pattern.append('rect')
.attr('width', 8)
.attr('height', 8)
.attr('fill', d3.color(color).darker(0.2));
pattern.append('line')
.attr('x1', 0)
.attr('y1', 4)
.attr('x2', 8)
.attr('y2', 4)
.attr('stroke', d3.color(color).brighter(0.5))
.attr('stroke-width', 2);
} else {
pattern.append('rect')
.attr('width', 8)
.attr('height', 8)
.attr('fill', d3.color(color).darker(0.2));
pattern.append('line')
.attr('x1', 4)
.attr('y1', 0)
.attr('x2', 4)
.attr('y2', 8)
.attr('stroke', d3.color(color).brighter(0.5))
.attr('stroke-width', 2);
}
return pattern;
}
// Create patterns for each color
Object.entries(colorMapping).forEach(([name, color], i) => {
createStripedPattern(`stripes-${name}`, color, i % 2 === 0 ? 'horizontal' : 'vertical');
});
// Tooltip setup
let tooltip = document.createElement('div');
tooltip.id = 'tooltip';
Object.assign(tooltip.style, {
position: 'absolute',
visibility: 'hidden',
background: '#fff',
borderRadius: '5px',
padding: '10px',
boxShadow: '0 0 10px rgba(0,0,0,0.25)',
pointerEvents: 'none',
maxWidth: '280px',
zIndex: 1000,
font: '13px sans-serif',
transition: 'opacity 0.3s ease',
opacity: 0
});
document.body.appendChild(tooltip);
// Function to create donut chart
function createDonutChart(data) {
svg.selectAll('*:not(defs)').remove();
const radius = Math.min(width, height) / 2 - 40;
const innerRadius = radius * 0.5;
// Main group
const g = svg.append('g')
.attr('transform', `translate(${width/2}, ${height/2})`);
// Calculate total for percentages
const total = d3.sum(data, d => d.value);
// Calculate percentage for each slice
data.forEach(d => {
d.percentage = (d.value / total) * 100;
});
// Sort data by value (descending)
data.sort((a, b) => b.value - a.value);
// Create pie generator
const pie = d3.pie()
.sort(null)
.padAngle(0.03)
.value(d => d.value);
const pieData = pie(data);
// Create arc generators for outer and inner rings
const outerArc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(radius);
// Function to create arc with custom thickness
function customArc(innerRadiusRatio, outerRadiusRatio) {
return d3.arc()
.innerRadius(radius * innerRadiusRatio)
.outerRadius(radius * outerRadiusRatio)
.padAngle(0.03)
.cornerRadius(5);
}
// Calculate segment size based on data value
const segmentScale = d3.scaleLinear()
.domain([d3.min(data, d => d.value), d3.max(data, d => d.value)])
.range([0.15, 0.3]);
// Create segments
pieData.forEach((d, i) => {
const segmentThickness = segmentScale(d.data.value);
const innerRatio = 0.5 + (i * 0.01); // Slight offset for each segment
const outerRatio = innerRatio + segmentThickness;
const arc = customArc(innerRatio, outerRatio);
// Create segment
const segment = g.append('path')
.datum(d)
.attr('d', arc)
.attr('fill', colorMapping[d.data.name])
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
.style('opacity', 0.9)
.on('mouseover', function(event, d) {
// Highlight segment
d3.select(this)
.transition()
.duration(200)
.attr('transform',
`scale(1.05) rotate(${(d.startAngle + d.endAngle) / 2 * (180/Math.PI)})`);
// Show tooltip
const eduC = getEducationCategory(d.data.avgEducation);
const ageC = getAgeCategory(d.data.avgAge);
tooltip.innerHTML = `
<div style="font-weight:bold;font-size:16px;margin-bottom:8px;">${d.data.name} Income Class</div>
<div><b>Percentage:</b> ${d.data.percentage.toFixed(1)}%</div>
<div><b>Food Insecurity:</b> ${d.data.value.toFixed(2)}%</div>
<div><b>Education:</b> ${d.data.avgEducation.toFixed(1)} yrs (${eduC})</div>
<div><b>Median Age:</b> ${d.data.avgAge.toFixed(1)} (${ageC})</div>
<div><b>Countries:</b> ${d.data.countryCount}</div>
`;
tooltip.style.visibility = 'visible';
tooltip.style.left = `${event.pageX + 10}px`;
tooltip.style.top = `${event.pageY - 10}px`;
tooltip.style.opacity = 1;
})
.on('mouseout', function() {
// Restore segment
d3.select(this)
.transition()
.duration(200)
.attr('transform', '');
// Hide tooltip
tooltip.style.opacity = 0;
tooltip.style.visibility = 'hidden';
});
// Add small stripes pattern on top
const patternArc = customArc(outerRatio - 0.03, outerRatio);
g.append('path')
.datum(d)
.attr('d', patternArc)
.attr('fill', `url(#stripes-${d.data.name})`)
.attr('stroke', '#fff')
.attr('stroke-width', 0.5)
.style('opacity', 0.8);
// Add percentage labels
if (d.data.percentage > 10) {
const labelArc = customArc((innerRatio + outerRatio) / 2 - 0.05, (innerRatio + outerRatio) / 2 + 0.05);
const pos = labelArc.centroid(d);
const midAngle = (d.startAngle + d.endAngle) / 2;
const isRight = midAngle < Math.PI;
g.append('text')
.attr('x', pos[0])
.attr('y', pos[1])
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('fill', 'white')
.attr('font-size', '16px')
.attr('font-weight', 'bold')
.text(`${d.data.percentage.toFixed(1)}%`);
}
});
// Create subtle connecting lines between segments
pieData.forEach((d, i) => {
if (i < pieData.length - 1) {
const nextD = pieData[i+1];
// Connect end of current to start of next
const startPoint = outerArc.centroid(d);
const endPoint = outerArc.centroid(nextD);
g.append('line')
.attr('x1', startPoint[0])
.attr('y1', startPoint[1])
.attr('x2', endPoint[0])
.attr('y2', endPoint[1])
.attr('stroke', '#fff')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.5)
.attr('stroke-dasharray', '3,3');
}
});
// Add center label
g.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', '18px')
.attr('font-weight', 'bold')
.text('Food Security');
g.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', '14px')
.attr('y', 25)
.text('by Income Class');
}
// Function to update chart data
function updateChart() {
const selectedYear = +yearSelect.value;
const selectedContinent = contSelect.value;
const data = processedByYearContinent[selectedYear][selectedContinent] || [];
subtitleContainer.textContent = `Year: ${selectedYear} | Continent: ${selectedContinent}`;
createDonutChart(data);
}
// Event listeners for controls
yearSelect.addEventListener('change', updateChart);
contSelect.addEventListener('change', updateChart);
// Initialize
updateChart();
// Add custom styles
const style = document.createElement('style');
style.textContent = `
select {
background-color: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
}
path:hover {
cursor: pointer;
}
`;
document.head.appendChild(style);
return container;
}