From 3da95a9ce4ae1064c1a2e978f73b1a0339e2a0d5 Mon Sep 17 00:00:00 2001 From: "raphael.fluckiger@gmail.com" Date: Mon, 5 May 2025 17:11:19 +0200 Subject: [PATCH 1/3] add source and destination country --- index.html | 10 ++ main.css | 107 +++++++++++++++- main.js | 356 +++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 401 insertions(+), 72 deletions(-) diff --git a/index.html b/index.html index 93bbc66..7276304 100644 --- a/index.html +++ b/index.html @@ -31,6 +31,16 @@

Welcome to Geo-Viz

+ +
+
+ Select Country +
+ + +
+
+
diff --git a/main.css b/main.css index d4833b3..afc9d80 100644 --- a/main.css +++ b/main.css @@ -168,15 +168,23 @@ body { display: block; } -/* Selected country styling */ -.selected-country { - stroke: #c629f1; - stroke-width: 6px; +/* Update the source and destination country styles to be more complete */ +.source-country { + stroke: #0066cc; + stroke-width: 4px; /* Match the JavaScript value */ stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; - fill-rule: evenodd; } + +.destination-country { + stroke: #cc6600; + stroke-width: 4px; /* Match the JavaScript value */ + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + .data-section { margin-bottom: 15px; padding-bottom: 10px; @@ -212,4 +220,93 @@ body { #reset-zoom:hover { background-color: #f0f0f0; +} + +/* Selection mode buttons */ +.selection-mode { + display: flex; + align-items: center; + margin-left: 15px; + gap: 10px; +} + +.mode-button { + padding: 5px 10px; + margin: 0 5px; + border: 1px solid #ccc; + background: #f5f5f5; + cursor: pointer; +} + +.mode-button.active { + background: #007bff; + color: white; + border-color: #0056b3; +} + +.selection-controls { + margin: 15px 0; + display: flex; + justify-content: center; +} + +.selection-mode { + display: flex; + align-items: center; + gap: 10px; +} + +.mode-label { + font-weight: 500; +} + +.button-group { + display: flex; +} + +.mode-button { + padding: 8px 15px; + border: 1px solid #ccc; + background: #f8f9fa; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; +} + +.mode-button:first-child { + border-radius: 4px 0 0 4px; + border-right: none; +} + +.mode-button:last-child { + border-radius: 0 4px 4px 0; +} + +.mode-button.active { + background: #007bff; + color: white; + border-color: #0056b3; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + position: relative; + z-index: 1; +} + +/* Trip details styling */ +.trip-details { + margin-top: 20px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 5px; + border-left: 4px solid #007bff; +} + +.carbon-data { + font-weight: bold; + color: #28a745; +} + +.note { + font-style: italic; + font-size: 0.9em; + color: #6c757d; } \ No newline at end of file diff --git a/main.js b/main.js index 50456d4..3043e41 100644 --- a/main.js +++ b/main.js @@ -11,13 +11,19 @@ const svg = d3.select("svg#world_map") const g = svg.append("g"); const zoom = d3.zoom() - .scaleExtent([1, 8]) // Set zoom limits + .scaleExtent([1, 8]) .on("zoom", (event) => { - g.attr("transform", event.transform); // Apply zoom transformation + g.attr("transform", event.transform); - const strokeWidth = 1.5 / event.transform.k; - g.selectAll("path.selected-country") - .attr("stroke-width", strokeWidth); + // Adjust stroke width based on zoom level but maintain styles + const sourceStrokeWidth = countryStyles.source.strokeWidth / event.transform.k; + const destStrokeWidth = countryStyles.destination.strokeWidth / event.transform.k; + + g.selectAll("path.source-country") + .attr("stroke-width", sourceStrokeWidth); + + g.selectAll("path.destination-country") + .attr("stroke-width", destStrokeWidth); }); svg.call(zoom); // Apply zoom behavior to the SVG @@ -85,6 +91,112 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis budget: "Trip Budget Map" }; + // Track selected countries and selection mode + let selectedCountries = { + source: null, + destination: null + }; + let selectionMode = 'source'; // Default selection mode + + // Add style for source and destination countries + const countryStyles = { + source: { + stroke: "#0066cc", + strokeWidth: 4, // Changed from "4px" to 4 + className: "source-country" + }, + destination: { + stroke: "#cc6600", + strokeWidth: 4, // Changed from "4px" to 4 + className: "destination-country" + } + }; + + // Enhanced event listeners for selection mode buttons with defensive checks + const sourceButton = d3.select("#source-mode"); + const destinationButton = d3.select("#destination-mode"); + const modeLabel = d3.select(".selection-mode .mode-label"); + + // Only add event listeners if the elements exist + if (!sourceButton.empty()) { + sourceButton.on("click", function() { + selectionMode = 'source'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + + // Visual indicator that shows which mode is active + if (!modeLabel.empty()) { + modeLabel.text("Select Source Country:") + .style("color", countryStyles.source.stroke); + } + }); + } + + if (!destinationButton.empty()) { + destinationButton.on("click", function() { + selectionMode = 'destination'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + + // Visual indicator that shows which mode is active + if (!modeLabel.empty()) { + modeLabel.text("Select Destination Country:") + .style("color", countryStyles.destination.stroke); + } + }); + } + + // Set initial label only if the element exists + if (!modeLabel.empty()) { + modeLabel.text("Select Source Country:") + .style("color", countryStyles.source.stroke); + } + + // Create selection controls if they don't exist + if (d3.select(".selection-controls").empty()) { + const mapContainer = d3.select(".map-container"); + + const controls = mapContainer.append("div") + .attr("class", "selection-controls"); + + const selectionMode = controls.append("div") + .attr("class", "selection-mode"); + + selectionMode.append("span") + .attr("class", "mode-label") + .text("Select Source Country:") + .style("color", countryStyles.source.stroke); + + const buttonGroup = selectionMode.append("div") + .attr("class", "button-group"); + + buttonGroup.append("button") + .attr("id", "source-mode") + .attr("class", "mode-button active") + .text("Source Country") + .on("click", function() { + selectionMode = 'source'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + d3.select(".mode-label") + .text("Select Source Country:") + .style("color", countryStyles.source.stroke); + }); + + buttonGroup.append("button") + .attr("id", "destination-mode") + .attr("class", "mode-button") + .text("Destination Country") + .on("click", function() { + selectionMode = 'destination'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + d3.select(".mode-label") + .text("Select Destination Country:") + .style("color", countryStyles.destination.stroke); + }); + } + let currentMonth = 1; // Default month for temperature map // Add slider for temperature map below the map title const slider = d3.select(".map-container") @@ -144,89 +256,107 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis centerOnCountry(d); }) .on("mouseover", function (event, d) { - d3.select(this) - .attr("stroke", "#333") - .attr("stroke-width", "4px") - .attr("cursor", "pointer") - .attr("vector-effect", "non-scaling-stroke"); + // Check if this country is already selected + const isSource = selectedCountries.source && selectedCountries.source.code === d.id; + const isDestination = selectedCountries.destination && selectedCountries.destination.code === d.id; + + // Don't override styling if it's already a selected country + if (!isSource && !isDestination) { + d3.select(this) + .attr("stroke", "#333") + .attr("stroke-width", 4) // Use same value as in countryStyles + .attr("cursor", "pointer") + .attr("vector-effect", "non-scaling-stroke"); + } }) .on("mouseout", function (event, d) { - if (!this.classList.contains('selected-country')) { + // Check if this is either a source or destination country + const isSource = selectedCountries.source && selectedCountries.source.code === d.id; + const isDestination = selectedCountries.destination && selectedCountries.destination.code === d.id; + + // Only remove styling if this is not a selected country + if (!isSource && !isDestination) { d3.select(this) .attr("stroke", null) .attr("stroke-width", null); + } else { + // Ensure proper styling is maintained for selected countries + if (isSource) { + d3.select(this) + .attr("stroke", countryStyles.source.stroke) + .attr("stroke-width", countryStyles.source.strokeWidth); + } else if (isDestination) { + d3.select(this) + .attr("stroke", countryStyles.destination.stroke) + .attr("stroke-width", countryStyles.destination.strokeWidth); + } } }); // Show or hide the slider based on the selected dataset slider.style("display", selectedDataset === "temperature" ? "block" : "none"); + + // Make sure country styling is maintained after map update + updateCountryStyles(); } // Function to handle country selection and display details function selectCountry(event, country, selectedDataset) { - // Reset all countries to default styling - g.selectAll("path").classed("selected-country", false) - .attr("stroke", null) - .attr("stroke-width", null); - - // Highlight the selected country - d3.select(event.currentTarget) - .classed("selected-country", true) - .attr("stroke", "#333") - .attr("stroke-width", "2px"); - // Get country data const countryCode = country.id; const countryName = country.properties.name; - - centerOnCountry(country); - - // Create HTML with all available data for this country - let detailHTML = `

${countryName}

`; - - // Temperature data - if (datasets.temperature[countryCode]) { - const temp = datasets.temperature[countryCode][currentMonth]; - detailHTML += ` -
-

Temperature

-

Average Temperature: ${temp !== undefined ? temp.toFixed(1) + "°C" : "Data not available"}

-

Month: ${new Date(0, currentMonth - 1).toLocaleString('default', { month: 'long' })}

-
- `; - } - - // Tourism popularity data - if (datasets.popularity[countryCode]) { - const popularity = datasets.popularity[countryCode]; - detailHTML += ` -
-

Tourism Popularity

-

Score: ${popularity !== undefined ? popularity.toFixed(2) : "Data not available"}

-
- `; + + // Check if this country is already selected in the other mode to avoid conflicts + const otherMode = selectionMode === 'source' ? 'destination' : 'source'; + + // If the same country is selected in both modes, clear the previous selection + if (selectedCountries[otherMode] && selectedCountries[otherMode].code === countryCode) { + selectedCountries[otherMode] = null; } + + // Record the selected country based on selection mode + selectedCountries[selectionMode] = { + code: countryCode, + name: countryName, + element: event.currentTarget, + country: country + }; + + // Apply proper styling to all countries + updateCountryStyles(); + + // Center map on the selected country + centerOnCountry(country); + + // Update the details panel + updateCountryDetails(selectedDataset); + } - // Budget data - if (datasets.budget[countryCode]) { - const budget = datasets.budget[countryCode]; - detailHTML += ` -
-

Trip Budget

-

Average Cost: ${budget !== undefined ? "$" + budget.toFixed(0) : "Data not available"}

-
- `; + // Function to handle country styling + function updateCountryStyles() { + // Reset all country styling first + g.selectAll("path") + .classed("source-country destination-country", false) + .attr("stroke", null) + .attr("stroke-width", null); + + // Apply styling to source country if selected + if (selectedCountries.source && selectedCountries.source.element) { + d3.select(selectedCountries.source.element) + .classed("source-country", true) + .attr("stroke", countryStyles.source.stroke) + .attr("stroke-width", countryStyles.source.strokeWidth) + .attr("vector-effect", "non-scaling-stroke"); } - - // If no data available for any dataset - if (!datasets.temperature[countryCode] && - !datasets.popularity[countryCode] && - !datasets.budget[countryCode]) { - detailHTML += "

No data available for this country

"; + + // Apply styling to destination country if selected + if (selectedCountries.destination && selectedCountries.destination.element) { + d3.select(selectedCountries.destination.element) + .classed("destination-country", true) + .attr("stroke", countryStyles.destination.stroke) + .attr("stroke-width", countryStyles.destination.strokeWidth) + .attr("vector-effect", "non-scaling-stroke"); } - - // Update the existing country-details div - d3.select(".country-details").html(detailHTML); } function centerOnCountry(country) { @@ -283,6 +413,98 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis ); } + // Function to update the country details panel + function updateCountryDetails(selectedDataset) { + let detailHTML = ''; + + // Source country section + if (selectedCountries.source) { + detailHTML += `

From: ${selectedCountries.source.name}

`; + detailHTML += generateCountryDataHTML(selectedCountries.source.code); + } else { + detailHTML += `

Select Source Country

+

Click on a country after selecting the "Source" mode

`; + } + + // Destination country section + if (selectedCountries.destination) { + detailHTML += `

To: ${selectedCountries.destination.name}

`; + detailHTML += generateCountryDataHTML(selectedCountries.destination.code); + + // Add section for trip details (carbon footprint placeholder) + if (selectedCountries.source) { + detailHTML += ` +
+

Trip Information

+

From ${selectedCountries.source.name} to ${selectedCountries.destination.name}

+

Carbon Footprint: Calculating...

+

Carbon footprint data will be available in the next update.

+
+ `; + } + } else { + detailHTML += `

Select Destination Country

+

Click on a country after selecting the "Destination" mode

`; + } + + // Update the existing country-details div + d3.select(".country-details").html(detailHTML); + + // If both countries are selected, we could call the carbon emissions API here in the future + if (selectedCountries.source && selectedCountries.destination) { + // Placeholder for future API call: + // calculateCarbonEmissions(selectedCountries.source.code, selectedCountries.destination.code); + } + } + + // Function to generate HTML for country data section + function generateCountryDataHTML(countryCode) { + let html = ''; + + // Temperature data + if (datasets.temperature[countryCode]) { + const temp = datasets.temperature[countryCode][currentMonth]; + html += ` +
+

Temperature

+

Average Temperature: ${temp !== undefined ? temp.toFixed(1) + "°C" : "Data not available"}

+

Month: ${new Date(0, currentMonth - 1).toLocaleString('default', { month: 'long' })}

+
+ `; + } + + // Tourism popularity data + if (datasets.popularity[countryCode]) { + const popularity = datasets.popularity[countryCode]; + html += ` +
+

Tourism Popularity

+

Score: ${popularity !== undefined ? popularity.toFixed(2) : "Data not available"}

+
+ `; + } + + // Budget data + if (datasets.budget[countryCode]) { + const budget = datasets.budget[countryCode]; + html += ` +
+

Trip Budget

+

Average Cost: ${budget !== undefined ? "$" + budget.toFixed(0) : "Data not available"}

+
+ `; + } + + // If no data available for any dataset + if (!datasets.temperature[countryCode] && + !datasets.popularity[countryCode] && + !datasets.budget[countryCode]) { + html += "

No data available for this country

"; + } + + return html; + } + // Initial map rendering with temperature data updateMap("temperature"); From 814e4ca1e39986b365e093b9657e5c627e4b68da Mon Sep 17 00:00:00 2001 From: "raphael.fluckiger@gmail.com" Date: Tue, 6 May 2025 10:23:17 +0200 Subject: [PATCH 2/3] add CO2 API call --- main.css | 61 ++++++++ main.js | 413 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 450 insertions(+), 24 deletions(-) diff --git a/main.css b/main.css index afc9d80..80c9bd6 100644 --- a/main.css +++ b/main.css @@ -309,4 +309,65 @@ body { font-style: italic; font-size: 0.9em; color: #6c757d; +} + +/* Loading spinner for API calls */ +.loading-spinner { + width: 30px; + height: 30px; + margin: 10px auto; + border: 3px solid #f3f3f3; + border-top: 3px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + display: inline-block; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Carbon visualization styles */ +.carbon-visualization { + width: 100%; + height: 15px; + background-color: #f0f0f0; + margin: 10px 0; + border-radius: 10px; + overflow: hidden; +} + +.carbon-bar { + height: 100%; + background: linear-gradient(to right, #28a745, #ffc107, #dc3545); + border-radius: 10px 0 0 10px; + transition: width 0.5s ease-in-out; +} + +.carbon-context { + font-weight: 500; + margin-top: 15px; + margin-bottom: 5px; +} + +.carbon-equivalents { + margin: 0; + padding-left: 20px; +} + +.carbon-equivalents li { + margin-bottom: 5px; + color: #555; +} + +.carbon-label { + font-weight: bold; + color: #444; + margin-right: 5px; +} + +.carbon-value { + font-weight: bold; + color: #28a745; } \ No newline at end of file diff --git a/main.js b/main.js index 3043e41..a113880 100644 --- a/main.js +++ b/main.js @@ -16,8 +16,8 @@ const zoom = d3.zoom() g.attr("transform", event.transform); // Adjust stroke width based on zoom level but maintain styles - const sourceStrokeWidth = countryStyles.source.strokeWidth / event.transform.k; - const destStrokeWidth = countryStyles.destination.strokeWidth / event.transform.k; + const sourceStrokeWidth = "4px"; + const destStrokeWidth = "4px"; g.selectAll("path.source-country") .attr("stroke-width", sourceStrokeWidth); @@ -42,6 +42,12 @@ const colorScales = { budget: d3.scaleLog().interpolate(d3.interpolateHcl).range(["#1a9850", "#d73027"]) // Green for low budget, red for high budget }; +// Add these variables at the top of your file (after the svg and projection definitions) +// Global variables for airport data +let airportDataLoaded = false; +let airportDataPromise = null; +let countryToAirport = {}; // Will be populated with country code -> airport code mapping + // Load data through promises const map_promise = d3.json("https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson") .then((topojson) => topojson.features); @@ -413,14 +419,14 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis ); } - // Function to update the country details panel + // Function to update the country details panel with carbon data function updateCountryDetails(selectedDataset) { let detailHTML = ''; // Source country section if (selectedCountries.source) { detailHTML += `

From: ${selectedCountries.source.name}

`; - detailHTML += generateCountryDataHTML(selectedCountries.source.code); + detailHTML += generateCountryDataHTML(selectedCountries.source.code, selectedDataset); } else { detailHTML += `

Select Source Country

Click on a country after selecting the "Source" mode

`; @@ -429,36 +435,86 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis // Destination country section if (selectedCountries.destination) { detailHTML += `

To: ${selectedCountries.destination.name}

`; - detailHTML += generateCountryDataHTML(selectedCountries.destination.code); - - // Add section for trip details (carbon footprint placeholder) - if (selectedCountries.source) { - detailHTML += ` -
-

Trip Information

-

From ${selectedCountries.source.name} to ${selectedCountries.destination.name}

-

Carbon Footprint: Calculating...

-

Carbon footprint data will be available in the next update.

-
- `; - } + detailHTML += generateCountryDataHTML(selectedCountries.destination.code, selectedDataset); } else { detailHTML += `

Select Destination Country

Click on a country after selecting the "Destination" mode

`; } - // Update the existing country-details div - d3.select(".country-details").html(detailHTML); - - // If both countries are selected, we could call the carbon emissions API here in the future + // Trip section if both countries are selected if (selectedCountries.source && selectedCountries.destination) { - // Placeholder for future API call: - // calculateCarbonEmissions(selectedCountries.source.code, selectedCountries.destination.code); + detailHTML += ` +
+

Trip Carbon Footprint

+

Flight from ${selectedCountries.source.name} to ${selectedCountries.destination.name}

+

+ CO2 Emissions: + Calculating... + +

+

Data provided by Carbon Interface API

+
+ `; + + // Update the DOM first with loading state + d3.select(".country-details").html(detailHTML); + + // Make the API call + calculateCarbonEmissions(selectedCountries.source, selectedCountries.destination) + .then(carbonData => { + if (!carbonData) return; + + // Hide spinner + d3.select(".loading-spinner").style("display", "none"); + + // Update carbon data display + d3.select(".carbon-data").html(`${carbonData.emissions} kg CO2 + ${carbonData.isEstimate ? '(estimated)' : ''}`); + + // Add additional details + const tripDetails = d3.select(".trip-details"); + + // Add distance information + tripDetails.append("p") + .html(`Distance: ${carbonData.distance} km (${carbonData.flightType})`); + + // Add visualization + tripDetails.append("div") + .attr("class", "carbon-visualization") + .append("div") + .attr("class", "carbon-bar") + .style("width", `${Math.min(carbonData.emissions/20, 100)}%`); + + // Add context information + tripDetails.append("p") + .attr("class", "carbon-context") + .text("Environmental impact equivalent to:"); + + const equivalentsList = tripDetails.append("ul") + .attr("class", "carbon-equivalents"); + + equivalentsList.append("li") + .text(`${Math.round(carbonData.emissions/2.5)} km driven by an average car`); + + equivalentsList.append("li") + .text(`${Math.round(carbonData.emissions/8.9)} hours of air conditioning`); + + equivalentsList.append("li") + .text(`${Math.round(carbonData.emissions/50)} trees needed to absorb this CO2 over one year`); + }) + .catch(error => { + console.error("Error calculating carbon:", error); + d3.select(".loading-spinner").style("display", "none"); + d3.select(".carbon-data").text("Calculation failed"); + }); + } else { + // Update the DOM for cases where both countries aren't selected + d3.select(".country-details").html(detailHTML); } } // Function to generate HTML for country data section - function generateCountryDataHTML(countryCode) { + function generateCountryDataHTML(countryCode, selectedDataset) { let html = ''; // Temperature data @@ -520,3 +576,312 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis .call(zoom.transform, d3.zoomIdentity); }); }); + + +// Simple fallback estimation based on distance +function estimateCarbonEmissions(sourceCountry, destCountry) { + // Calculate distance between country centroids + const sourceCentroid = d3.geoCentroid(sourceCountry.country); + const destCentroid = d3.geoCentroid(destCountry.country); + const distance = d3.geoDistance(sourceCentroid, destCentroid) * 6371; // Earth radius in km + + // Simple emissions calculation (kg CO2 per passenger km) + const emissionFactor = distance < 1500 ? 0.15 : (distance < 4000 ? 0.12 : 0.11); + const emissions = distance * emissionFactor; + + return { + distance: Math.round(distance), + emissions: Math.round(emissions * 10) / 10, + sourceAirport: getAirportCode(sourceCountry.code), + destAirport: getAirportCode(destCountry.code), + isEstimate: true + }; +} + + + +// Helper function to load ISO3 to ISO2 country code mapping +async function loadISO3To2Mapping() { + try { + const response = await fetch('https://raw.githubusercontent.com/lukes/ISO-3166-Countries-with-Regional-Codes/master/slim-2/slim-2.json'); + const countries = await response.json(); + + const mapping = {}; + countries.forEach(country => { + mapping[country['alpha-3']] = country['alpha-2']; + // Also map by country name for flexibility + mapping[country.name] = country['alpha-2']; + }); + + return mapping; + } catch (error) { + console.error('Error loading ISO code mapping:', error); + return {}; + } +} + +// Load airport data at the beginning of your app +countryToAirport = {}; // Will be populated with country code -> airport code mapping + +// Load the airport data at startup +loadAirportData().then(data => { + countryToAirport = data; + console.log("Airport data loaded"); +}); + +// Updated loadAirportData function that returns a promise +function loadAirportData() { + // Only load once + if (airportDataPromise) { + return airportDataPromise; + } + + // Create and store the promise + airportDataPromise = new Promise(async (resolve) => { + try { + console.log("Loading airport data..."); + const response = await fetch('https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat'); + const text = await response.text(); + + // Process CSV-like data + const airports = []; + text.split('\n').forEach(line => { + if (!line) return; + // Parse the comma-separated values (handling quoted fields) + const fields = line.match(/("([^"]*)"|([^,]*))(,|$)/g)?.map(field => { + return field.replace(/(^,|,$)/g, '').replace(/^"(.*)"$/, '$1'); + }); + + if (fields && fields.length >= 8) { + airports.push({ + id: fields[0], + name: fields[1], + city: fields[2], + country: fields[3], + iata: fields[4] !== '\\N' ? fields[4] : null, + icao: fields[5] !== '\\N' ? fields[5] : null, + latitude: parseFloat(fields[6]), + longitude: parseFloat(fields[7]), + altitude: parseInt(fields[8]), + size: parseInt(fields[9] || 0) // Use size/importance metric + }); + } + }); + + // Create a map of countries to their major airports + const countryAirports = {}; + + // Group airports by country + const airportsByCountry = {}; + airports.forEach(airport => { + if (!airport.iata) return; // Skip airports without IATA codes + + const countryName = airport.country; + if (!airportsByCountry[countryName]) { + airportsByCountry[countryName] = []; + } + airportsByCountry[countryName].push(airport); + }); + + // For each country, find the largest/most important airport + Object.keys(airportsByCountry).forEach(countryName => { + // Sort by international status, size, and other factors + const sortedAirports = airportsByCountry[countryName].sort((a, b) => { + // First, prioritize airports with "International" in the name + const aIsIntl = a.name.toLowerCase().includes('international'); + const bIsIntl = b.name.toLowerCase().includes('international'); + if (aIsIntl && !bIsIntl) return -1; + if (!aIsIntl && bIsIntl) return 1; + + // Next, prioritize by size if available + if (a.size && b.size && a.size !== b.size) { + return b.size - a.size; + } + + // Finally, prefer capital city airports + const capitalCities = ['london', 'paris', 'berlin', 'madrid', 'rome', 'washington', 'beijing', 'tokyo']; + const aIsCapital = capitalCities.some(capital => a.city.toLowerCase().includes(capital)); + const bIsCapital = capitalCities.some(capital => b.city.toLowerCase().includes(capital)); + if (aIsCapital && !bIsCapital) return -1; + if (!aIsCapital && bIsCapital) return 1; + + return 0; + }); + + if (sortedAirports.length > 0) { + // Store by country name + countryAirports[countryName] = sortedAirports[0].iata; + + // Also try to map to ISO3 codes using a basic mapping + // This is just a basic example - you'd need a more comprehensive mapping + const iso3Mapping = { + 'United States': 'USA', + 'United Kingdom': 'GBR', + 'France': 'FRA', + 'Germany': 'DEU', + 'China': 'CHN', + 'Japan': 'JPN', + 'Algeria': 'DZA', + // Add more as needed + }; + + if (iso3Mapping[countryName]) { + countryAirports[iso3Mapping[countryName]] = sortedAirports[0].iata; + } + } + }); + + console.log(`Loaded airports for ${Object.keys(countryAirports).length} countries`); + airportDataLoaded = true; + resolve(countryAirports); + } catch (error) { + console.error('Error loading airport data:', error); + airportDataLoaded = false; + resolve({}); // Resolve with empty object on error + } + }); + + return airportDataPromise; +} + +// Updated Carbon Interface API function +async function calculateCarbonEmissions(sourceCountry, destCountry) { + if (!sourceCountry || !destCountry) return null; + + try { + // First check hardcoded mapping - prioritize these over dynamic data + const hardcodedAirports = getAirportCode(sourceCountry.code); + const hardcodedDestination = getAirportCode(destCountry.code); + + // Start with hardcoded values for major countries + let sourceAirport = hardcodedAirports; + let destAirport = hardcodedDestination; + + // Only use dynamic data if hardcoded mapping didn't work + if (sourceAirport === sourceCountry.code || destAirport === destCountry.code) { + // Make sure airport data is loaded + if (!airportDataLoaded) { + countryToAirport = await loadAirportData(); + } + + // Only override sourceAirport if it wasn't already matched + if (sourceAirport === sourceCountry.code) { + sourceAirport = countryToAirport[sourceCountry.name] || + countryToAirport[sourceCountry.code] || + sourceCountry.code; + } + + // Only override destAirport if it wasn't already matched + if (destAirport === destCountry.code) { + destAirport = countryToAirport[destCountry.name] || + countryToAirport[destCountry.code] || + destCountry.code; + } + } + + // Log the resolution process + console.log(`Country codes: ${sourceCountry.code} → ${destCountry.code}`); + console.log(`Using airports: ${sourceAirport} → ${destAirport}`); + + // If we can't resolve both airports, fall back to estimation + if (!sourceAirport || !destAirport) { + console.warn("Could not resolve airport codes, using fallback estimation"); + return estimateCarbonEmissions(sourceCountry, destCountry); + } + + // Continue with the API call using the resolved airport codes + const payload = { + "type": "flight", + "passengers": 1, + "legs": [ + { + "departure_airport": sourceAirport, + "destination_airport": destAirport + } + ] + }; + + // Rest of your existing API call code... + const response = await fetch('https://www.carboninterface.com/api/v1/estimates', { + method: 'POST', + headers: { + 'Authorization': 'Bearer W3pfo842IxX3bhoQ3j40wQ', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`API response error: ${response.status}`); + } + + const data = await response.json(); + console.log('Carbon API response:', data); + + return { + distance: Math.round(data.data.attributes.distance_value || 0), + emissions: Math.round(data.data.attributes.carbon_kg * 10) / 10, + flightType: data.data.attributes.distance_value < 1500 ? 'short-haul' : + (data.data.attributes.distance_value < 4000 ? 'medium-haul' : 'long-haul'), + sourceAirport: sourceAirport, + destAirport: destAirport + }; + } catch (error) { + console.error('Error calculating carbon emissions:', error); + return estimateCarbonEmissions(sourceCountry, destCountry); + } +} + +// Update where you start loading airport data +// Initialize immediately so it's ready when needed +loadAirportData().then(data => { + countryToAirport = data; + console.log("Airport data loaded successfully:", Object.keys(data).length, "countries mapped"); +}).catch(error => { + console.error("Failed to load airport data:", error); +}); + +// Simplified airport code mapping using CCA3 country codes +function getAirportCode(countryCode) { + const airportMap = { + // Europe + "GBR": "LHR", // London Heathrow (not Belfast) + "UKR": "KBP", // Kyiv Boryspil (not Simferopol) + "FRA": "CDG", // Paris Charles de Gaulle + "DEU": "FRA", // Frankfurt + "ITA": "FCO", // Rome Fiumicino + "ESP": "MAD", // Madrid Barajas + "NLD": "AMS", // Amsterdam Schiphol + + // North America + "USA": "JFK", // New York JFK + "CAN": "YYZ", // Toronto + "MEX": "MEX", // Mexico City + + // Asia + "CHN": "PEK", // Beijing + "JPN": "HND", // Tokyo Haneda + "IND": "DEL", // Delhi + "THA": "BKK", // Bangkok + "SGP": "SIN", // Singapore Changi + + // Middle East & Africa + "ARE": "DXB", // Dubai + "ZAF": "JNB", // Johannesburg + "EGY": "CAI", // Cairo + "DZA": "ALG", // Algiers + "MAR": "CMN", // Casablanca + + // South America + "BRA": "GRU", // São Paulo + "ARG": "EZE", // Buenos Aires + "CHL": "SCL", // Santiago + "COL": "BOG", // Bogotá + + // Oceania + "AUS": "SYD", // Sydney + "NZL": "AKL" // Auckland + }; + + return airportMap[countryCode] || countryCode; +} From fcbf658889a484cebbe7817da7e835399c951035 Mon Sep 17 00:00:00 2001 From: "raphael.fluckiger@gmail.com" Date: Tue, 6 May 2025 11:31:52 +0200 Subject: [PATCH 3/3] add carbon budget --- main.css | 150 ++++++++++++++++++++++++++-- main.js | 298 ++++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 381 insertions(+), 67 deletions(-) diff --git a/main.css b/main.css index 80c9bd6..07479f9 100644 --- a/main.css +++ b/main.css @@ -130,6 +130,7 @@ body { justify-content: flex-start; align-items: flex-start; border-left: 1px solid #ddd; + padding-top: 15px; } .info-panel h2 { @@ -231,11 +232,14 @@ body { } .mode-button { - padding: 5px 10px; + padding: 8px 15px; margin: 0 5px; - border: 1px solid #ccc; - background: #f5f5f5; + border: 2px solid #ccc; + background-color: #f8f8f8; + border-radius: 5px; cursor: pointer; + font-weight: 500; + transition: all 0.2s ease-in-out; } .mode-button.active { @@ -282,13 +286,28 @@ body { border-radius: 0 4px 4px 0; } -.mode-button.active { - background: #007bff; - color: white; - border-color: #0056b3; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); - position: relative; - z-index: 1; + + +/* Default state - subtle styling */ +.mode-button:hover { + border-color: #999; + background-color: #eee; +} + +/* Source button when active */ +.mode-button.source.active { + border-color: #0066cc; + background-color: rgba(0, 102, 204, 0.1); + color: #0066cc; + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); +} + +/* Destination button when active */ +.mode-button.destination.active { + border-color: #cc6600; + background-color: rgba(204, 102, 0, 0.1); + color: #cc6600; + box-shadow: 0 0 0 2px rgba(204, 102, 0, 0.2); } /* Trip details styling */ @@ -370,4 +389,115 @@ body { .carbon-value { font-weight: bold; color: #28a745; +} + +/* Panel selection controls styling */ +.panel-controls { + width: 100%; + border-bottom: 1px solid #eee; + padding-bottom: 15px; + margin-bottom: 20px; +} + +.panel-controls .button-group { + display: flex; + width: 100%; +} + +.panel-controls .mode-button { + flex: 1; + text-align: center; + padding: 10px; + border: 1px solid #ddd; + background-color: #f8f8f8; + transition: all 0.2s ease; + font-weight: 500; +} + +.panel-controls .mode-button:first-child { + border-radius: 4px 0 0 4px; +} + +.panel-controls .mode-button:last-child { + border-radius: 0 4px 4px 0; +} + +/* Keep the specific active styles for source and destination */ +.panel-controls .mode-button.source.active { + border-color: #0066cc; + background-color: rgba(0, 102, 204, 0.1); + color: #0066cc; + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); +} + +.panel-controls .mode-button.destination.active { + border-color: #cc6600; + background-color: rgba(204, 102, 0, 0.1); + color: #cc6600; + box-shadow: 0 0 0 2px rgba(204, 102, 0, 0.2); +} + +/* Make the right panel more organized */ +.info-panel { + display: flex; + flex-direction: column; + padding-top: 15px; +} + +/* Update the title styling */ +.panel-controls h3 { + color: #333; + font-size: 1.2rem; + margin-bottom: 12px; +} + +/* Add to your CSS file */ +.carbon-budget-title { + margin-top: 20px; + margin-bottom: 10px; + font-weight: 600; +} + +.carbon-budget-comparison { + margin: 15px 0; +} + +.budget-bar-container { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.flight-portion { + transition: width 0.5s ease-in-out; +} + +.budget-labels { + color: #555; + font-size: 12px; +} + +.max-trips-info { + background-color: #f3f9ff; + padding: 10px; + border-radius: 4px; + margin-top: 12px; + border-left: 3px solid #4dabf7; +} + +.carbon-recommendation { + font-weight: 500; +} + +.trip-icons { + margin: 10px 0 15px 0; +} + +.trip-icon { + animation: bounce 1s ease infinite; + animation-delay: calc(var(--i, 0) * 0.1s); +} + +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-5px); } } \ No newline at end of file diff --git a/main.js b/main.js index a113880..daa8a85 100644 --- a/main.js +++ b/main.js @@ -125,37 +125,40 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis // Only add event listeners if the elements exist if (!sourceButton.empty()) { - sourceButton.on("click", function() { - selectionMode = 'source'; - d3.selectAll(".mode-button").classed("active", false); - d3.select(this).classed("active", true); - - // Visual indicator that shows which mode is active - if (!modeLabel.empty()) { - modeLabel.text("Select Source Country:") - .style("color", countryStyles.source.stroke); - } - }); + sourceButton + .attr("class", "mode-button source active") // Add 'source' class + .on("click", function() { + selectionMode = 'source'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + + // Hide or minimize the text label + if (!modeLabel.empty()) { + modeLabel.style("display", "none"); // Hide the label completely + // Or make it smaller: modeLabel.style("font-size", "0.8em").text("Source mode active"); + } + }); } if (!destinationButton.empty()) { - destinationButton.on("click", function() { - selectionMode = 'destination'; - d3.selectAll(".mode-button").classed("active", false); - d3.select(this).classed("active", true); - - // Visual indicator that shows which mode is active - if (!modeLabel.empty()) { - modeLabel.text("Select Destination Country:") - .style("color", countryStyles.destination.stroke); - } - }); + destinationButton + .attr("class", "mode-button destination") // Add 'destination' class + .on("click", function() { + selectionMode = 'destination'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + + // Hide or minimize the text label + if (!modeLabel.empty()) { + modeLabel.style("display", "none"); // Hide the label completely + // Or make it smaller: modeLabel.style("font-size", "0.8em").text("Destination mode active"); + } + }); } - // Set initial label only if the element exists + // Set initial label state if (!modeLabel.empty()) { - modeLabel.text("Select Source Country:") - .style("color", countryStyles.source.stroke); + modeLabel.style("display", "none"); // Hide initially } // Create selection controls if they don't exist @@ -178,28 +181,24 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis buttonGroup.append("button") .attr("id", "source-mode") - .attr("class", "mode-button active") + .attr("class", "mode-button source active") // Add 'source' class .text("Source Country") .on("click", function() { selectionMode = 'source'; d3.selectAll(".mode-button").classed("active", false); d3.select(this).classed("active", true); - d3.select(".mode-label") - .text("Select Source Country:") - .style("color", countryStyles.source.stroke); + d3.select(".mode-label").style("display", "none"); // Hide the label }); buttonGroup.append("button") .attr("id", "destination-mode") - .attr("class", "mode-button") + .attr("class", "mode-button destination") // Add 'destination' class .text("Destination Country") .on("click", function() { selectionMode = 'destination'; d3.selectAll(".mode-button").classed("active", false); d3.select(this).classed("active", true); - d3.select(".mode-label") - .text("Select Destination Country:") - .style("color", countryStyles.destination.stroke); + d3.select(".mode-label").style("display", "none"); // Hide the label }); } @@ -467,40 +466,134 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis // Hide spinner d3.select(".loading-spinner").style("display", "none"); - // Update carbon data display - d3.select(".carbon-data").html(`${carbonData.emissions} kg CO2 + // Calculate round-trip emissions (double the one-way) + const roundTripEmissions = carbonData.emissions * 2; + + // Update carbon data display to show round trip + d3.select(".carbon-data").html(`${roundTripEmissions} kg CO2 (round trip) ${carbonData.isEstimate ? '(estimated)' : ''}`); // Add additional details const tripDetails = d3.select(".trip-details"); - // Add distance information + // Add distance information (also doubled for round trip) tripDetails.append("p") - .html(`Distance: ${carbonData.distance} km (${carbonData.flightType})`); + .html(`Distance: ${carbonData.distance * 2} km (${carbonData.flightType}, round trip)`); - // Add visualization - tripDetails.append("div") - .attr("class", "carbon-visualization") - .append("div") - .attr("class", "carbon-bar") - .style("width", `${Math.min(carbonData.emissions/20, 100)}%`); + // Add carbon budget comparison section + const annualBudget = 2000; // 2000kg CO2 sustainable annual budget per person + const percentOfBudget = Math.round((roundTripEmissions / annualBudget) * 100); - // Add context information + // Calculate max trips per year to stay within budget + const maxTripsPerYear = Math.floor(annualBudget / roundTripEmissions); + + tripDetails.append("h4") + .attr("class", "carbon-budget-title") + .text("Carbon Budget Impact"); + tripDetails.append("p") - .attr("class", "carbon-context") - .text("Environmental impact equivalent to:"); + .html(`This round trip uses ${percentOfBudget}% of the recommended annual carbon budget of ${annualBudget}kg CO2 per person.`); + + + // Add comparison bar visualization + const comparisonViz = tripDetails.append("div") + .attr("class", "carbon-budget-comparison"); + + // Container for the budget bar + const budgetBar = comparisonViz.append("div") + .attr("class", "budget-bar-container") + .style("position", "relative") + .style("height", "30px") + .style("width", "100%") + .style("background-color", "#e0e0e0") + .style("border-radius", "4px") + .style("margin", "10px 0"); + + // Flight's portion of annual budget + budgetBar.append("div") + .attr("class", "flight-portion") + .style("position", "absolute") + .style("height", "100%") + .style("width", `${Math.min(percentOfBudget, 100)}%`) + .style("background-color", percentOfBudget > 50 ? "#d32f2f" : "#ff9800") + .style("border-radius", "4px 0 0 4px") + .style("transition", "width 0.5s ease-in-out"); + + // Labels for the visualization + comparisonViz.append("div") + .attr("class", "budget-labels") + .style("display", "flex") + .style("justify-content", "space-between") + .style("margin-top", "5px") + .html(` + 0% + 50% + 100% of annual budget + `); + + // Add "Max Trips" visual representation + const maxTripsViz = tripDetails.append("div") + .attr("class", "max-trips-viz") + .style("margin-top", "20px"); + + maxTripsViz.append("h5") + .style("margin", "0 0 10px 0") + .text("Maximum Round Trips Per Year"); + + const tripIcons = maxTripsViz.append("div") + .attr("class", "trip-icons") + .style("display", "flex") + .style("gap", "5px") + .style("flex-wrap", "wrap"); - const equivalentsList = tripDetails.append("ul") - .attr("class", "carbon-equivalents"); + // Show plane icons representing each possible trip (up to 10) + const displayLimit = Math.min(maxTripsPerYear, 10); // Limit display to 10 icons - equivalentsList.append("li") - .text(`${Math.round(carbonData.emissions/2.5)} km driven by an average car`); + for (let i = 0; i < displayLimit; i++) { + tripIcons.append("div") + .attr("class", "trip-icon") + .style("width", "30px") + .style("height", "30px") + .style("background-color", "#007bff") + .style("border-radius", "50%") + .style("display", "flex") + .style("align-items", "center") + .style("justify-content", "center") + .style("color", "white") + .style("font-weight", "bold") + .text("✈️"); + } + + // If there are more than the display limit, show a +X more indicator + if (maxTripsPerYear > displayLimit) { + tripIcons.append("div") + .attr("class", "more-trips") + .style("padding", "5px 8px") + .style("background-color", "#eee") + .style("border-radius", "15px") + .style("font-size", "12px") + .style("color", "#555") + .text(`+${maxTripsPerYear - displayLimit} more`); + } - equivalentsList.append("li") - .text(`${Math.round(carbonData.emissions/8.9)} hours of air conditioning`); + // Color-coded recommendation based on number of trips + const recommendationColor = maxTripsPerYear < 1 ? "#d32f2f" : + (maxTripsPerYear < 3 ? "#ff9800" : "#4caf50"); - equivalentsList.append("li") - .text(`${Math.round(carbonData.emissions/50)} trees needed to absorb this CO2 over one year`); + const recommendationText = maxTripsPerYear < 1 ? + "This destination exceeds your annual sustainable carbon budget." : + (maxTripsPerYear < 3 ? + "Consider visiting this destination less frequently or staying longer." : + "This destination can be visited sustainably within your annual carbon budget."); + + tripDetails.append("div") + .attr("class", "carbon-recommendation-box") + .style("margin-top", "15px") + .style("padding", "12px") + .style("border-radius", "4px") + .style("border-left", `4px solid ${recommendationColor}`) + .style("background-color", "#f9f9f9") + .html(`

${recommendationText}

`); }) .catch(error => { console.error("Error calculating carbon:", error); @@ -575,8 +668,40 @@ Promise.all([map_promise, temperature_promise, popularity_promise, budget_promis .duration(750) .call(zoom.transform, d3.zoomIdentity); }); -}); + // Create the selection controls in the info panel + const controls = createSelectionControlsInPanel(); + + // Add country click handler to select countries + g.selectAll("path") + .on("click", function(event, d) { + // Remove existing hover styling + d3.select(this) + .attr("stroke", null) + .attr("stroke-width", null); + + // Call the selectCountry function with the country data + selectCountry(event, d, "temperature"); // Pass the event, country data, and a default dataset + }); + + // Remove any old event handlers to avoid duplicates + d3.selectAll(".mode-button").on("click", null); + + // Reattach the event handlers + controls.sourceButton.on("click", function() { + selectionMode = 'source'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + console.log("Selection mode set to source"); + }); + + controls.destinationButton.on("click", function() { + selectionMode = 'destination'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + console.log("Selection mode set to destination"); + }); +}); // Simple fallback estimation based on distance function estimateCarbonEmissions(sourceCountry, destCountry) { @@ -598,8 +723,6 @@ function estimateCarbonEmissions(sourceCountry, destCountry) { }; } - - // Helper function to load ISO3 to ISO2 country code mapping async function loadISO3To2Mapping() { try { @@ -885,3 +1008,64 @@ function getAirportCode(countryCode) { return airportMap[countryCode] || countryCode; } + +// Create selection controls in the info panel +function createSelectionControlsInPanel() { + // Clean up any existing controls first + d3.selectAll(".selection-controls").remove(); + + // Select the info panel + const infoPanel = d3.select(".info-panel"); + if (infoPanel.empty()) { + console.error("Info panel not found"); + return { sourceButton: null, destinationButton: null }; // Return empty object as fallback + } + + // Create a container for the selection controls at the top of the panel + const selectionControls = infoPanel.insert("div", ":first-child") + .attr("class", "selection-controls panel-controls") + .style("margin-bottom", "20px"); + + // Add a title + selectionControls.append("h3") + .text("Select Countries") + .style("margin-top", "0") + .style("margin-bottom", "10px"); + + // Create the button group + const buttonGroup = selectionControls.append("div") + .attr("class", "button-group"); + + // Create the source button + const sourceButton = buttonGroup.append("button") + .attr("id", "source-mode") + .attr("class", "mode-button source active") + .text("Source Country") + .on("click", function() { + selectionMode = 'source'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + console.log("Selection mode set to source"); + }); + + // Create the destination button + const destinationButton = buttonGroup.append("button") + .attr("id", "destination-mode") + .attr("class", "mode-button destination") + .text("Destination Country") + .on("click", function() { + selectionMode = 'destination'; + d3.selectAll(".mode-button").classed("active", false); + d3.select(this).classed("active", true); + console.log("Selection mode set to destination"); + }); + + // Remove any old selection controls from other places + d3.select(".selection-controls:not(.panel-controls)").remove(); + + // Return the buttons for further reference + return { + sourceButton: sourceButton, + destinationButton: destinationButton + }; +}