Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>World Development Indicators</title> | |
| <script src="https://d3js.org/d3.v7.min.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| body { | |
| font-family: 'Arial', sans-serif; | |
| margin: 0; | |
| padding: 20px; | |
| background-color: #f5f7fa; | |
| color: #333; | |
| } | |
| .container { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| background-color: white; | |
| border-radius: 10px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| padding: 30px; | |
| } | |
| h1 { | |
| text-align: center; | |
| color: #2c3e50; | |
| margin-bottom: 10px; | |
| font-size: 2.2em; | |
| } | |
| .subtitle { | |
| text-align: center; | |
| color: #7f8c8d; | |
| margin-bottom: 30px; | |
| font-size: 1.1em; | |
| } | |
| .controls { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 15px; | |
| margin-bottom: 25px; | |
| align-items: center; | |
| padding: 20px; | |
| background-color: #f8f9fa; | |
| border-radius: 8px; | |
| box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08); | |
| } | |
| .filter-buttons { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| margin-right: auto; | |
| } | |
| .filter-btn { | |
| padding: 8px 16px; | |
| border: none; | |
| border-radius: 25px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: all 0.3s; | |
| box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); | |
| font-size: 0.9em; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .filter-btn i { | |
| margin-right: 5px; | |
| font-size: 0.9em; | |
| } | |
| .filter-btn:hover { | |
| opacity: 0.9; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| .filter-btn.active { | |
| transform: scale(1.05); | |
| box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); | |
| } | |
| .year-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 15px; | |
| background-color: rgba(255, 255, 255, 0.8); | |
| padding: 10px 15px; | |
| border-radius: 30px; | |
| box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); | |
| } | |
| .year-slider { | |
| width: 250px; | |
| -webkit-appearance: none; | |
| height: 6px; | |
| background: #e0e0e0; | |
| border-radius: 5px; | |
| outline: none; | |
| } | |
| .year-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #3498db; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .year-slider::-webkit-slider-thumb:hover { | |
| background: #2980b9; | |
| transform: scale(1.1); | |
| } | |
| .play-btn { | |
| background-color: #2ecc71; | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 30px; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-weight: 600; | |
| box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); | |
| } | |
| .play-btn:hover { | |
| background-color: #27ae60; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| .play-btn.playing { | |
| background-color: #e74c3c; | |
| } | |
| .year-display { | |
| font-weight: bold; | |
| min-width: 50px; | |
| text-align: center; | |
| font-size: 1.1em; | |
| color: #2c3e50; | |
| background-color: #f8f9fa; | |
| padding: 5px 10px; | |
| border-radius: 20px; | |
| box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); | |
| } | |
| .chart-container { | |
| width: 100%; | |
| height: 650px; | |
| position: relative; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| background-color: white; | |
| box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1); | |
| border: 1px solid #eee; | |
| } | |
| .tooltip { | |
| position: absolute; | |
| padding: 15px; | |
| background: rgba(0, 0, 0, 0.9); | |
| color: white; | |
| border-radius: 8px; | |
| pointer-events: none; | |
| text-align: left; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| font-size: 14px; | |
| z-index: 10; | |
| box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); | |
| max-width: 240px; | |
| line-height: 1.5; | |
| backdrop-filter: blur(2px); | |
| } | |
| .tooltip:after { | |
| content: ""; | |
| position: absolute; | |
| top: 100%; | |
| left: 50%; | |
| margin-left: -8px; | |
| border-width: 8px; | |
| border-style: solid; | |
| border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent; | |
| } | |
| .loading { | |
| text-align: center; | |
| padding: 50px; | |
| font-size: 18px; | |
| color: #7f8c8d; | |
| } | |
| .spinner { | |
| border: 4px solid rgba(0, 0, 0, 0.1); | |
| border-radius: 50%; | |
| border-top: 4px solid #3498db; | |
| width: 30px; | |
| height: 30px; | |
| animation: spin 1s linear infinite; | |
| margin: 0 auto 20px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .axis-label { | |
| font-size: 12px; | |
| fill: #555; | |
| font-weight: 500; | |
| } | |
| .disclaimer { | |
| text-align: center; | |
| font-size: 13px; | |
| color: #7f8c8d; | |
| margin-top: 20px; | |
| line-height: 1.5; | |
| } | |
| .chart-title { | |
| font-size: 14px; | |
| font-weight: bold; | |
| fill: #444; | |
| } | |
| @media (max-width: 768px) { | |
| .container { | |
| padding: 15px; | |
| } | |
| .controls { | |
| flex-direction: column; | |
| align-items: stretch; | |
| } | |
| .filter-buttons { | |
| margin-right: 0; | |
| margin-bottom: 15px; | |
| justify-content: center; | |
| } | |
| .year-controls { | |
| width: 100%; | |
| justify-content: space-between; | |
| } | |
| .year-slider { | |
| width: 100%; | |
| } | |
| .chart-container { | |
| height: 500px; | |
| } | |
| h1 { | |
| font-size: 1.8em; | |
| } | |
| .subtitle { | |
| font-size: 1em; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>World Development Indicators</h1> | |
| <p class="subtitle">Life Expectancy vs. GDP per capita (1950-2023)</p> | |
| <div class="controls"> | |
| <div class="filter-buttons" id="region-buttons"> | |
| <!-- Region buttons will be dynamically generated here --> | |
| </div> | |
| <div class="year-controls"> | |
| <button class="play-btn" id="play-btn"> | |
| <i class="fas fa-play"></i> Play | |
| </button> | |
| <span class="year-display" id="year-value">1990</span> | |
| <input type="range" class="year-slider" id="year-slider" min="1950" max="2023" value="1990" step="1"> | |
| </div> | |
| </div> | |
| <div class="chart-container" id="chart-area"> | |
| <div class="loading" id="loading"> | |
| <div class="spinner"></div> | |
| <p>Loading data visualization...</p> | |
| </div> | |
| </div> | |
| <div class="tooltip" id="tooltip"></div> | |
| <p class="disclaimer">Note: For countries missing region data in their year, we use the region from their latest available data.<br>Some countries/territories may not have complete indicator coverage for all years.</p> | |
| </div> | |
| <script> | |
| // Configuration | |
| const config = { | |
| margin: { top: 60, right: 60, bottom: 80, left: 90 }, | |
| minBubbleSize: 3, | |
| maxBubbleSize: 45, | |
| animationDuration: 500, | |
| playInterval: 800, | |
| regions: { | |
| "Africa": { color: "#e74c3c", icon: "globe-africa" }, | |
| "Asia": { color: "#3498db", icon: "globe-asia" }, | |
| "Europe": { color: "#2ecc71", icon: "globe-europe" }, | |
| "North America": { color: "#f1c40f", icon: "globe-americas" }, | |
| "Oceania": { color: "#9b59b6", icon: "map-marked-alt" }, | |
| "South America": { color: "#1abc9c", icon: "map-marked-alt" }, | |
| "Antarctica": { color: "#95a5a6", icon: "snowflake" } | |
| } | |
| }; | |
| // State | |
| let state = { | |
| data: null, | |
| filteredData: null, | |
| countryRegions: {}, // Store the most recent region for each country | |
| minYear: 1950, | |
| maxYear: 2023, | |
| currentYear: 1990, | |
| activeRegions: Object.keys(config.regions), | |
| isPlaying: false, | |
| playIntervalId: null, | |
| svg: null, | |
| x: null, | |
| y: null, | |
| size: null | |
| }; | |
| // DOM elements | |
| const dom = { | |
| chartArea: d3.select("#chart-area"), | |
| loading: d3.select("#loading"), | |
| tooltip: d3.select("#tooltip"), | |
| yearSlider: d3.select("#year-slider"), | |
| yearValue: d3.select("#year-value"), | |
| playBtn: d3.select("#play-btn"), | |
| regionButtons: d3.select("#region-buttons") | |
| }; | |
| // Initialize the visualization | |
| async function init() { | |
| // Load data from CSV file | |
| await loadData(); | |
| // Setup UI controls | |
| setupControls(); | |
| // Initialize chart | |
| initChart(); | |
| // Render initial view | |
| updateChart(); | |
| // Hide loading | |
| dom.loading.style("display", "none"); | |
| } | |
| // Load and process data from CSV | |
| async function loadData() { | |
| try { | |
| // Load the CSV data | |
| const rawData = await d3.csv("world-data.csv"); | |
| // First pass: Create a lookup table for country regions from the most recent data | |
| const latestYearData = {}; | |
| // Find all unique entities | |
| const entities = new Set(rawData.map(d => d.Entity)); | |
| // For each entity, find the most recent year that has region data | |
| entities.forEach(entity => { | |
| // Get all records for this entity, sorted by year (descending) | |
| const entityData = rawData | |
| .filter(d => d.Entity === entity) | |
| .sort((a, b) => b.Year - a.Year); | |
| // Find the first record with region data | |
| const latestDataWithRegion = entityData.find(d => | |
| d['World regions according to OWID'] && d['World regions according to OWID'] !== "" | |
| ); | |
| if (latestDataWithRegion) { | |
| latestYearData[entity] = latestDataWithRegion['World regions according to OWID']; | |
| } else { | |
| // Fallback if no region data exists at all | |
| latestYearData[entity] = 'Unknown'; | |
| } | |
| }); | |
| // Process the data with region fallback | |
| const processedData = rawData.map(d => { | |
| // Use the region from our lookup table as fallback | |
| const region = d['World regions according to OWID'] || | |
| latestYearData[d.Entity] || | |
| 'Unknown'; | |
| return { | |
| entity: d.Entity, | |
| code: d.Code, | |
| year: +d.Year, | |
| lifeExpectancy: d['Life expectancy - Sex: all - Age: 0 - Variant: estimates'] ? | |
| +d['Life expectancy - Sex: all - Age: 0 - Variant: estimates'] : null, | |
| gdpPerCapita: d['GDP per capita, PPP (constant 2021 international $)'] ? | |
| +d['GDP per capita, PPP (constant 2021 international $)'] : null, | |
| population: d['Population (historical)'] ? | |
| +d['Population (historical)'] : null, | |
| region: region | |
| }; | |
| }).filter(d => | |
| d.lifeExpectancy !== null && | |
| d.gdpPerCapita !== null && | |
| d.population !== null | |
| ); | |
| // Filter out any invalid data points | |
| state.data = processedData.filter(d => | |
| !isNaN(d.year) && | |
| !isNaN(d.lifeExpectancy) && | |
| !isNaN(d.gdpPerCapita) && | |
| !isNaN(d.population) | |
| ); | |
| // Determine min and max years from data | |
| state.minYear = d3.min(state.data, d => d.year); | |
| state.maxYear = d3.max(state.data, d => d.year); | |
| state.currentYear = state.minYear; | |
| // Update slider range | |
| dom.yearSlider.attr("min", state.minYear) | |
| .attr("max", state.maxYear) | |
| .attr("value", state.currentYear); | |
| dom.yearValue.text(state.currentYear); | |
| console.log("Data loaded successfully:", state.data); | |
| } catch (error) { | |
| console.error("Error loading data:", error); | |
| dom.loading.html(`<p style="color: #e74c3c;">Error loading data. Please check if the 'world-data.csv' file exists.</p>`); | |
| } | |
| } | |
| // Setup UI controls | |
| function setupControls() { | |
| // Create region buttons | |
| dom.regionButtons.selectAll("*").remove(); | |
| Object.entries(config.regions).forEach(([region, { color, icon }]) => { | |
| const btn = dom.regionButtons.append("button") | |
| .attr("class", "filter-btn active") | |
| .style("background-color", color) | |
| .html(`<i class="fas fa-${icon}"></i> ${region}`) | |
| .on("click", function() { | |
| const isActive = d3.select(this).classed("active"); | |
| d3.select(this).classed("active", !isActive); | |
| if (isActive) { | |
| state.activeRegions = state.activeRegions.filter(r => r !== region); | |
| } else { | |
| state.activeRegions.push(region); | |
| } | |
| updateChart(); | |
| }); | |
| }); | |
| // Year slider event | |
| dom.yearSlider.on("input", function() { | |
| state.currentYear = +this.value; | |
| dom.yearValue.text(state.currentYear); | |
| updateChart(); | |
| }); | |
| // Play button event | |
| dom.playBtn.on("click", function() { | |
| state.isPlaying = !state.isPlaying; | |
| if (state.isPlaying) { | |
| d3.select(this).classed("playing", true) | |
| .html('<i class="fas fa-pause"></i> Pause'); | |
| state.playIntervalId = setInterval(() => { | |
| state.currentYear = state.currentYear < state.maxYear ? | |
| state.currentYear + 1 : state.minYear; | |
| dom.yearSlider.property("value", state.currentYear); | |
| dom.yearValue.text(state.currentYear); | |
| updateChart(); | |
| }, config.playInterval); | |
| } else { | |
| d3.select(this).classed("playing", false) | |
| .html('<i class="fas fa-play"></i> Play'); | |
| if (state.playIntervalId) { | |
| clearInterval(state.playIntervalId); | |
| } | |
| } | |
| }); | |
| } | |
| // Initialize the chart structure | |
| function initChart() { | |
| // Clear any existing SVG | |
| dom.chartArea.selectAll("svg").remove(); | |
| // Create SVG | |
| const width = dom.chartArea.node().clientWidth; | |
| const height = dom.chartArea.node().clientHeight; | |
| state.svg = dom.chartArea.append("svg") | |
| .attr("width", width) | |
| .attr("height", height); | |
| // Create main chart group | |
| const chartGroup = state.svg.append("g") | |
| .attr("transform", `translate(${config.margin.left}, ${config.margin.top})`); | |
| // Create scales | |
| const innerWidth = width - config.margin.left - config.margin.right; | |
| const innerHeight = height - config.margin.top - config.margin.bottom; | |
| state.x = d3.scaleLog() | |
| .range([0, innerWidth]); | |
| state.y = d3.scaleLinear() | |
| .range([innerHeight, 0]); | |
| state.size = d3.scaleSqrt() | |
| .range([config.minBubbleSize, config.maxBubbleSize]); | |
| // Add axes groups | |
| chartGroup.append("g") | |
| .attr("class", "x-axis") | |
| .attr("transform", `translate(0, ${innerHeight})`); | |
| chartGroup.append("g") | |
| .attr("class", "y-axis"); | |
| // Add axis labels | |
| chartGroup.append("text") | |
| .attr("class", "axis-label") | |
| .attr("x", innerWidth / 2) | |
| .attr("y", innerHeight + 50) | |
| .text("GDP per capita (PPP, constant 2021 international $)"); | |
| chartGroup.append("text") | |
| .attr("class", "axis-label") | |
| .attr("transform", "rotate(-90)") | |
| .attr("x", -innerHeight / 2) | |
| .attr("y", -50) | |
| .text("Life Expectancy (years)"); | |
| // Add title | |
| chartGroup.append("text") | |
| .attr("class", "chart-title") | |
| .attr("x", innerWidth / 2) | |
| .attr("y", -30) | |
| .attr("text-anchor", "middle") | |
| .text("Life Expectancy vs. GDP per capita"); | |
| // Add year display on chart | |
| chartGroup.append("text") | |
| .attr("id", "chart-year") | |
| .attr("x", innerWidth - 10) | |
| .attr("y", -10) | |
| .attr("text-anchor", "end") | |
| .style("font-size", "28px") | |
| .style("font-weight", "bold") | |
| .style("fill", "#2c3e50") | |
| .style("opacity", 0.9) | |
| .text(state.currentYear); | |
| } | |
| // Update chart with current data | |
| function updateChart() { | |
| if (!state.data) return; | |
| // Filter data for current year and active regions | |
| state.filteredData = state.data.filter(d => | |
| d.year === state.currentYear && | |
| state.activeRegions.includes(d.region) | |
| ); | |
| // Get SVG dimensions | |
| const width = dom.chartArea.node().clientWidth; | |
| const height = dom.chartArea.node().clientHeight; | |
| const innerWidth = width - config.margin.left - config.margin.right; | |
| const innerHeight = height - config.margin.top - config.margin.bottom; | |
| // Update scales | |
| state.x.domain([ | |
| d3.min(state.data, d => d.gdpPerCapita) * 0.8, | |
| d3.max(state.data, d => d.gdpPerCapita) * 1.2 | |
| ]); | |
| state.y.domain([ | |
| d3.min(state.data, d => d.lifeExpectancy) * 0.9, | |
| d3.max(state.data, d => d.lifeExpectancy) * 1.05 | |
| ]); | |
| state.size.domain([ | |
| d3.min(state.data, d => d.population), | |
| d3.max(state.data, d => d.population) | |
| ]); | |
| // Update axes | |
| const xAxis = d3.axisBottom(state.x).ticks(5, "$,.0f"); | |
| const yAxis = d3.axisLeft(state.y); | |
| state.svg.select(".x-axis") | |
| .transition() | |
| .duration(config.animationDuration) | |
| .call(xAxis); | |
| state.svg.select(".y-axis") | |
| .transition() | |
| .duration(config.animationDuration) | |
| .call(yAxis); | |
| // Update year display | |
| state.svg.select("#chart-year") | |
| .text(state.currentYear); | |
| // Create transition for bubbles | |
| const t = state.svg.transition() | |
| .duration(config.animationDuration); | |
| // Bind data to circles | |
| const circles = state.svg.selectAll("g.country") | |
| .data(state.filteredData, d => d.code + d.year); | |
| // Exit old bubbles | |
| circles.exit() | |
| .transition(t) | |
| .attr("r", 0) | |
| .remove(); | |
| // Enter new bubbles | |
| const newCircles = circles.enter() | |
| .append("g") | |
| .attr("class", "country") | |
| .attr("transform", d => { | |
| const xPos = state.x(d.gdpPerCapita) + config.margin.left; | |
| const yPos = state.y(d.lifeExpectancy) + config.margin.top; | |
| return `translate(${xPos}, ${yPos})`; | |
| }) | |
| .on("mouseover", function(event, d) { | |
| dom.tooltip.style("opacity", 1) | |
| .html(` | |
| <div style="margin-bottom: 5px; font-size: 16px; font-weight: bold; color: ${config.regions[d.region].color}"> | |
| <i class="fas fa-${config.regions[d.region].icon}" style="margin-right: 5px;"></i>${d.entity} | |
| </div> | |
| <div><strong>Year:</strong> ${d.year}</div> | |
| <div><strong>Region:</strong> ${d.region}</div> | |
| <div><strong>Life Expectancy:</strong> ${d.lifeExpectancy.toFixed(1)} years</div> | |
| <div><strong>GDP per capita:</strong> $${d.gdpPerCapita.toLocaleString('en-US', {maximumFractionDigits: 0})}</div> | |
| <div><strong>Population:</strong> ${d3.format(",.0f")(d.population)}</div> | |
| `) | |
| .style("left", (event.pageX + 10) + "px") | |
| .style("top", (event.pageY - 10) + "px"); | |
| }) | |
| .on("mouseout", function() { | |
| dom.tooltip.style("opacity", 0); | |
| }); | |
| newCircles.append("circle") | |
| .attr("r", 0) | |
| .attr("fill", d => config.regions[d.region] ? config.regions[d.region].color : "#95a5a6") | |
| .attr("opacity", 0.8) | |
| .attr("stroke", "#fff") | |
| .attr("stroke-width", 1.5) | |
| .transition(t) | |
| .attr("r", d => state.size(d.population)); | |
| // Update existing bubbles | |
| circles.transition(t) | |
| .attr("transform", d => { | |
| const xPos = state.x(d.gdpPerCapita) + config.margin.left; | |
| const yPos = state.y(d.lifeExpectancy) + config.margin.top; | |
| return `translate(${xPos}, ${yPos})`; | |
| }) | |
| .select("circle") | |
| .attr("r", d => state.size(d.population)) | |
| .attr("fill", d => config.regions[d.region] ? config.regions[d.region].color : "#95a5a6"); | |
| // Add country labels for larger bubbles | |
| circles.selectAll("text").remove(); | |
| newCircles.filter(d => state.size(d.population) > 15) | |
| .append("text") | |
| .attr("dy", ".3em") | |
| .style("text-anchor", "middle") | |
| .style("font-size", "10px") | |
| .style("font-weight", "bold") | |
| .style("fill", "#fff") | |
| .style("pointer-events", "none") | |
| .text(d => d.code); | |
| } | |
| // Initialize the application | |
| window.addEventListener("load", init); | |
| // Handle window resize | |
| window.addEventListener("resize", function() { | |
| if (state.svg) { | |
| initChart(); | |
| updateChart(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |