421 lines
16 KiB
JavaScript
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');
|