infinite-agents-public/mapbox_test/mapbox_globe_9/src/index.js

421 lines
16 KiB
JavaScript

// Mapbox Globe Iteration 9: Global Educational Funding Choropleth
// Applying choropleth techniques learned from Mapbox documentation
// Mapbox access token
mapboxgl.accessToken = 'pk.eyJ1IjoibGludXhpc2Nvb2wiLCJhIjoiY2w3ajM1MnliMDV4NDNvb2J5c3V5dzRxZyJ9.wJukH5hVSiO74GM_VSJR3Q';
// Initialize map with globe projection
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/dark-v11',
projection: 'globe',
center: [20, 20],
zoom: 1.8,
pitch: 0
});
// Data processing: calculate funding metrics
const processedData = educationalFundingData.features.map(feature => {
const props = feature.properties;
return {
...feature,
properties: {
...props,
fundingGap: props.spendingPerCapita < 500 ? 'Critical' :
props.spendingPerCapita < 1000 ? 'High' :
props.spendingPerCapita < 2000 ? 'Moderate' : 'Low',
efficiency: props.spendingPerCapita > 0 ?
(props.educationIndex / (props.spendingPerCapita / 1000)).toFixed(2) : 0,
salaryGap: props.avgTeacherSalary < 10000 ? 'Low' :
props.avgTeacherSalary < 30000 ? 'Medium' :
props.avgTeacherSalary < 50000 ? 'Good' : 'High'
}
};
});
// Country rankings by spending
const countryRankings = [...processedData].sort(
(a, b) => b.properties.spendingPerCapita - a.properties.spendingPerCapita
);
// Statistics
const stats = {
avgSpending: processedData.reduce((sum, f) => sum + f.properties.spendingPerCapita, 0) / processedData.length,
maxSpending: Math.max(...processedData.map(f => f.properties.spendingPerCapita)),
minSpending: Math.min(...processedData.map(f => f.properties.spendingPerCapita)),
totalCenters: trainingCentersData.features.length,
avgTeacherSalary: processedData.reduce((sum, f) => sum + f.properties.avgTeacherSalary, 0) / processedData.length
};
// Update info panel
document.getElementById('avg-spending').textContent = `$${stats.avgSpending.toFixed(0)}`;
document.getElementById('total-countries').textContent = processedData.length;
document.getElementById('total-centers').textContent = stats.totalCenters;
document.getElementById('avg-salary').textContent = `$${stats.avgTeacherSalary.toFixed(0)}`;
map.on('load', () => {
// Configure globe atmosphere
map.setFog({
color: 'rgba(10, 15, 35, 0.9)',
'high-color': 'rgba(25, 50, 100, 0.5)',
'horizon-blend': 0.3,
'space-color': '#000814',
'star-intensity': 0.7
});
// Add country polygons source from Mapbox
// We'll use a custom source with our data for choropleth
map.addSource('countries', {
type: 'vector',
url: 'mapbox://mapbox.country-boundaries-v1'
});
// Create a lookup map for quick access to country data
const countryDataMap = new Map();
processedData.forEach(feature => {
countryDataMap.set(feature.properties.iso, feature.properties);
});
// Add choropleth fill layer using data-driven styling
// Technique learned from Mapbox documentation: interpolate expression for continuous color scaling
map.addLayer({
'id': 'country-choropleth',
'type': 'fill',
'source': 'countries',
'source-layer': 'country_boundaries',
'paint': {
// Choropleth fill-color using interpolate expression
// This creates a smooth color gradient based on data values
'fill-color': [
'case',
['has', 'iso_3166_1'],
[
'interpolate',
['linear'],
// Access spending data by matching ISO codes
['get', 'spendingPerCapita', ['literal', Object.fromEntries(countryDataMap)]],
0, '#440154', // Very low - deep purple
500, '#31688e', // Low - blue
1000, '#35b779', // Medium - green
2000, '#fde724', // High - yellow
5000, '#fde724' // Very high - bright yellow
],
'#1a1a1a' // Default for countries without data
],
'fill-opacity': 0.8
}
}, 'admin-1-boundary-bg');
// However, the above approach won't work directly because we can't join external data to vector tiles easily
// Let's use a different approach: convert our point data to a GeoJSON with polygons
// For this iteration, we'll create country fills from our point data
// Add educational funding data source (points)
map.addSource('education-data', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: processedData
}
});
// Add circle layer with size based on spending
map.addLayer({
'id': 'education-circles',
'type': 'circle',
'source': 'education-data',
'paint': {
// Circle radius based on spending per capita
'circle-radius': [
'interpolate',
['linear'],
['get', 'spendingPerCapita'],
0, 3,
500, 8,
1000, 12,
2000, 18,
5000, 25
],
// Choropleth-style color using interpolate (learned technique)
'circle-color': [
'interpolate',
['linear'],
['get', 'spendingPerCapita'],
0, '#440154', // Very low - deep purple (viridis scale)
500, '#31688e', // Low - blue
1000, '#35b779', // Medium - green
2000, '#fde724', // High - yellow
5000, '#fde724' // Very high - bright yellow
],
'circle-opacity': 0.85,
'circle-stroke-width': 2,
'circle-stroke-color': [
'interpolate',
['linear'],
['get', 'educationIndex'],
0.3, '#ff0000', // Low quality - red
0.6, '#ffaa00', // Medium quality - orange
0.8, '#00ff00' // High quality - green
],
'circle-stroke-opacity': 0.9
}
});
// Add teacher training centers
map.addSource('training-centers', {
type: 'geojson',
data: trainingCentersData
});
// Training centers as small points
map.addLayer({
'id': 'training-centers-layer',
'type': 'circle',
'source': 'training-centers',
'paint': {
'circle-radius': 4,
'circle-color': '#ff6b6b',
'circle-opacity': 0.7,
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
'circle-stroke-opacity': 0.8
}
});
// Add halo effect for training centers
map.addLayer({
'id': 'training-centers-halo',
'type': 'circle',
'source': 'training-centers',
'paint': {
'circle-radius': 8,
'circle-color': '#ff6b6b',
'circle-opacity': 0.2,
'circle-blur': 0.8
}
}, 'training-centers-layer');
// Country labels layer
map.addLayer({
'id': 'country-labels',
'type': 'symbol',
'source': 'education-data',
'layout': {
'text-field': ['get', 'country'],
'text-size': 11,
'text-offset': [0, 2],
'text-anchor': 'top',
'text-optional': true
},
'paint': {
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
'text-opacity': [
'interpolate',
['linear'],
['zoom'],
1.5, 0,
2.5, 0.7
]
}
});
// Create popup
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
maxWidth: '350px'
});
// Hover interaction for countries
map.on('mousemove', 'education-circles', (e) => {
map.getCanvas().style.cursor = 'pointer';
const props = e.features[0].properties;
const coordinates = e.features[0].geometry.coordinates.slice();
// Calculate additional metrics
const fundingRank = countryRankings.findIndex(f => f.properties.country === props.country) + 1;
const percentOfMax = ((props.spendingPerCapita / stats.maxSpending) * 100).toFixed(1);
const html = `
<div style="padding: 8px; font-family: 'Inter', sans-serif;">
<h3 style="margin: 0 0 10px 0; color: #fde724; font-size: 16px; font-weight: 600;">
${props.country}
</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px;">
<div>
<div style="color: #aaa; margin-bottom: 2px;">Spending/Capita</div>
<div style="color: #fff; font-weight: 600; font-size: 14px;">$${props.spendingPerCapita}</div>
<div style="color: #888; font-size: 10px;">Rank: #${fundingRank} (${percentOfMax}% of max)</div>
</div>
<div>
<div style="color: #aaa; margin-bottom: 2px;">Budget %</div>
<div style="color: #fff; font-weight: 600; font-size: 14px;">${props.budgetPercent}%</div>
</div>
<div>
<div style="color: #aaa; margin-bottom: 2px;">Avg Teacher Salary</div>
<div style="color: #fff; font-weight: 600;">$${props.avgTeacherSalary.toLocaleString()}</div>
</div>
<div>
<div style="color: #aaa; margin-bottom: 2px;">Teacher:Student</div>
<div style="color: #fff; font-weight: 600;">1:${props.teacherStudentRatio}</div>
</div>
<div>
<div style="color: #aaa; margin-bottom: 2px;">Education Index</div>
<div style="color: ${props.educationIndex > 0.8 ? '#00ff00' : props.educationIndex > 0.6 ? '#ffaa00' : '#ff0000'}; font-weight: 600;">${props.educationIndex}</div>
</div>
<div>
<div style="color: #aaa; margin-bottom: 2px;">Training Centers</div>
<div style="color: #ff6b6b; font-weight: 600;">${props.trainingCenters}</div>
</div>
</div>
<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid #333;">
<div style="color: #aaa; font-size: 11px;">Funding Gap:
<span style="color: ${props.fundingGap === 'Critical' ? '#ff0000' : props.fundingGap === 'High' ? '#ff8800' : props.fundingGap === 'Moderate' ? '#ffaa00' : '#00ff00'}; font-weight: 600;">
${props.fundingGap}
</span>
</div>
<div style="color: #aaa; font-size: 11px; margin-top: 4px;">Efficiency Score:
<span style="color: #35b779; font-weight: 600;">${props.efficiency}</span>
</div>
</div>
</div>
`;
popup.setLngLat(coordinates).setHTML(html).addTo(map);
});
map.on('mouseleave', 'education-circles', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
// Hover interaction for training centers
map.on('mousemove', 'training-centers-layer', (e) => {
map.getCanvas().style.cursor = 'pointer';
const props = e.features[0].properties;
const coordinates = e.features[0].geometry.coordinates.slice();
const html = `
<div style="padding: 8px; font-family: 'Inter', sans-serif;">
<h4 style="margin: 0 0 6px 0; color: #ff6b6b; font-size: 14px;">
Teacher Training Center
</h4>
<div style="font-size: 12px;">
<div style="color: #aaa;">Country: <span style="color: #fff;">${props.country}</span></div>
<div style="color: #aaa;">Capacity: <span style="color: #fff;">${props.capacity} students</span></div>
<div style="color: #aaa;">Program: <span style="color: #fff;">${props.programTypes}</span></div>
<div style="color: #aaa;">Est: <span style="color: #fff;">${props.yearEstablished}</span></div>
</div>
</div>
`;
popup.setLngLat(coordinates).setHTML(html).addTo(map);
});
map.on('mouseleave', 'training-centers-layer', () => {
map.getCanvas().style.cursor = '';
popup.remove();
});
// Click to fly to country
map.on('click', 'education-circles', (e) => {
const coordinates = e.features[0].geometry.coordinates;
map.flyTo({
center: coordinates,
zoom: 4,
duration: 2000,
pitch: 45,
bearing: 0
});
});
// Slow rotation animation
let userInteracting = false;
const rotationSpeed = 0.15;
const rotateCamera = (timestamp) => {
if (!userInteracting) {
map.rotateTo((timestamp / 200) % 360, { duration: 0 });
}
requestAnimationFrame(rotateCamera);
};
map.on('mousedown', () => { userInteracting = true; });
map.on('mouseup', () => { userInteracting = false; });
map.on('dragend', () => { userInteracting = false; });
map.on('pitchend', () => { userInteracting = false; });
map.on('rotateend', () => { userInteracting = false; });
// Start rotation
rotateCamera(0);
// Layer toggle functionality
document.getElementById('toggle-centers').addEventListener('click', (e) => {
const visibility = map.getLayoutProperty('training-centers-layer', 'visibility');
if (visibility === 'visible') {
map.setLayoutProperty('training-centers-layer', 'visibility', 'none');
map.setLayoutProperty('training-centers-halo', 'visibility', 'none');
e.target.textContent = 'Show Training Centers';
e.target.classList.remove('active');
} else {
map.setLayoutProperty('training-centers-layer', 'visibility', 'visible');
map.setLayoutProperty('training-centers-halo', 'visibility', 'visible');
e.target.textContent = 'Hide Training Centers';
e.target.classList.add('active');
}
});
document.getElementById('toggle-labels').addEventListener('click', (e) => {
const visibility = map.getLayoutProperty('country-labels', 'visibility');
if (visibility === 'visible') {
map.setLayoutProperty('country-labels', 'visibility', 'none');
e.target.textContent = 'Show Labels';
e.target.classList.remove('active');
} else {
map.setLayoutProperty('country-labels', 'visibility', 'visible');
e.target.textContent = 'Hide Labels';
e.target.classList.add('active');
}
});
// Populate top countries list
const topList = document.getElementById('top-countries-list');
countryRankings.slice(0, 10).forEach((feature, index) => {
const props = feature.properties;
const item = document.createElement('div');
item.className = 'country-item';
item.innerHTML = `
<span class="rank">#${index + 1}</span>
<span class="name">${props.country}</span>
<span class="value">$${props.spendingPerCapita}</span>
`;
item.addEventListener('click', () => {
map.flyTo({
center: feature.geometry.coordinates,
zoom: 4,
duration: 2000,
pitch: 45
});
});
topList.appendChild(item);
});
console.log('Globe loaded successfully!');
console.log(`Total countries: ${processedData.length}`);
console.log(`Total training centers: ${stats.totalCenters}`);
console.log(`Average spending: $${stats.avgSpending.toFixed(2)}`);
console.log(`Top 5 countries by spending:`, countryRankings.slice(0, 5).map(f =>
`${f.properties.country} ($${f.properties.spendingPerCapita})`
));
});
// Add navigation controls
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
// Add fullscreen control
map.addControl(new mapboxgl.FullscreenControl(), 'top-right');