874 lines
35 KiB
HTML
874 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>COVID-19 Vaccination Timeline - Global Equity Analysis</title>
|
|
|
|
<!-- Mapbox GL JS -->
|
|
<script src='https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js'></script>
|
|
<link href='https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css' rel='stylesheet' />
|
|
|
|
<!-- Chart.js for dual-axis charts -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: linear-gradient(135deg, #0a0e1a 0%, #1a1f35 100%);
|
|
color: #e5e7eb;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#map {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Timeline Control Panel */
|
|
.timeline-control {
|
|
position: absolute;
|
|
top: 20px;
|
|
left: 20px;
|
|
background: rgba(10, 14, 26, 0.95);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
width: 400px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
z-index: 1000;
|
|
}
|
|
|
|
.timeline-control .header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.timeline-control h3 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #f3f4f6;
|
|
margin: 0;
|
|
}
|
|
|
|
.year-display {
|
|
font-size: 14px;
|
|
color: #9ca3af;
|
|
background: rgba(59, 130, 246, 0.2);
|
|
padding: 6px 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
.year-display span {
|
|
font-weight: 700;
|
|
color: #60a5fa;
|
|
font-size: 16px;
|
|
}
|
|
|
|
/* Slider */
|
|
#year-slider {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: linear-gradient(to right,
|
|
#dc2626 0%,
|
|
#f59e0b 33%,
|
|
#10b981 66%,
|
|
#3b82f6 100%
|
|
);
|
|
border-radius: 4px;
|
|
outline: none;
|
|
-webkit-appearance: none;
|
|
margin-bottom: 20px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#year-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 24px;
|
|
height: 24px;
|
|
background: white;
|
|
cursor: pointer;
|
|
border-radius: 50%;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
|
border: 3px solid #3b82f6;
|
|
}
|
|
|
|
#year-slider::-moz-range-thumb {
|
|
width: 24px;
|
|
height: 24px;
|
|
background: white;
|
|
cursor: pointer;
|
|
border-radius: 50%;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
|
border: 3px solid #3b82f6;
|
|
}
|
|
|
|
/* Controls */
|
|
.controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.btn {
|
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
.btn:hover {
|
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
|
|
}
|
|
|
|
.btn:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.btn.playing {
|
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
|
}
|
|
|
|
label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 13px;
|
|
color: #d1d5db;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type="checkbox"] {
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Global Statistics */
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.stat {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.stat .label {
|
|
display: block;
|
|
font-size: 11px;
|
|
color: #9ca3af;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat .value {
|
|
display: block;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: #f3f4f6;
|
|
}
|
|
|
|
/* Dual-Axis Popup Styling */
|
|
.mapboxgl-popup-content {
|
|
background: rgba(10, 14, 26, 0.98);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
min-width: 460px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
.mapboxgl-popup-tip {
|
|
border-top-color: rgba(10, 14, 26, 0.98) !important;
|
|
}
|
|
|
|
.advanced-popup h3 {
|
|
margin: 0 0 16px 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #f3f4f6;
|
|
border-bottom: 2px solid rgba(59, 130, 246, 0.3);
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.advanced-popup canvas {
|
|
border-radius: 8px;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
/* Legend */
|
|
.legend {
|
|
position: absolute;
|
|
bottom: 30px;
|
|
right: 20px;
|
|
background: rgba(10, 14, 26, 0.95);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
z-index: 1000;
|
|
min-width: 220px;
|
|
}
|
|
|
|
.legend h4 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 13px;
|
|
color: #9ca3af;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.legend-gradient {
|
|
height: 20px;
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.legend-labels {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
color: #78909c;
|
|
}
|
|
|
|
/* Info Panel */
|
|
.info-panel {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: rgba(10, 14, 26, 0.95);
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
max-width: 320px;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.info-panel h4 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #f3f4f6;
|
|
}
|
|
|
|
.info-panel p {
|
|
margin: 0 0 8px 0;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
color: #d1d5db;
|
|
}
|
|
|
|
.info-panel .highlight {
|
|
color: #60a5fa;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
|
|
<!-- Timeline Control -->
|
|
<div class="timeline-control">
|
|
<div class="header">
|
|
<h3>COVID-19 Timeline</h3>
|
|
<div class="year-display">Year: <span id="current-year">2023</span></div>
|
|
</div>
|
|
|
|
<input id="year-slider" type="range" min="0" max="3" step="1" value="3">
|
|
|
|
<div class="controls">
|
|
<button id="play-pause" class="btn">▶ Play</button>
|
|
<button id="reset" class="btn">Reset</button>
|
|
<label>
|
|
<input type="checkbox" id="loop-checkbox" checked> Loop
|
|
</label>
|
|
</div>
|
|
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<span class="label">Global Coverage</span>
|
|
<span class="value" id="global-coverage">--</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="label">Total Cases</span>
|
|
<span class="value" id="total-cases">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="legend">
|
|
<h4>Vaccination Coverage (%)</h4>
|
|
<div class="legend-gradient" style="background: linear-gradient(to right, #dc2626, #f59e0b, #10b981, #3b82f6);"></div>
|
|
<div class="legend-labels">
|
|
<span>0%</span>
|
|
<span>50%</span>
|
|
<span>100%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Panel -->
|
|
<div class="info-panel">
|
|
<h4>COVID-19 Vaccination Equity (2020-2023)</h4>
|
|
<p>This visualization reveals the <span class="highlight">stark inequities</span> in global COVID-19 vaccine distribution.</p>
|
|
<p>High-income nations achieved <span class="highlight">75%+ coverage</span> by 2022, while low-income countries struggled to reach <span class="highlight">25%</span>.</p>
|
|
<p><strong>Hover over countries</strong> to see dual-axis charts comparing vaccination coverage against COVID-19 cases.</p>
|
|
</div>
|
|
|
|
<script type="module">
|
|
// Import shared configuration
|
|
import { MAPBOX_CONFIG } from '../../mapbox_test/shared/mapbox-config.js';
|
|
import { LayerFactory } from '../../mapbox_test/shared/layer-factory.js';
|
|
|
|
// Apply Mapbox token
|
|
MAPBOX_CONFIG.applyToken();
|
|
|
|
// Timeline configuration
|
|
const YEARS = [2020, 2021, 2022, 2023];
|
|
let currentYearIndex = 3; // Start at 2023
|
|
let isPlaying = false;
|
|
let playInterval = null;
|
|
|
|
// Chart management
|
|
let activeChart = null;
|
|
let activePopup = null;
|
|
let pinnedPopups = []; // Track multiple pinned popups
|
|
let chartCounter = 0;
|
|
let popupTimeout = null;
|
|
|
|
// Initialize map
|
|
const map = new mapboxgl.Map({
|
|
container: 'map',
|
|
style: 'mapbox://styles/mapbox/dark-v11',
|
|
projection: 'globe',
|
|
zoom: 1.5,
|
|
center: [20, 20],
|
|
pitch: 0
|
|
});
|
|
|
|
// Generate realistic COVID-19 vaccination data
|
|
function generateCovidData() {
|
|
const countries = [
|
|
// High-income countries - rapid vaccination
|
|
{ name: 'United States', coords: [-95, 37], income: 'high', region: 'Americas', pop: 331000000 },
|
|
{ name: 'United Kingdom', coords: [-3, 54], income: 'high', region: 'Europe', pop: 67000000 },
|
|
{ name: 'Germany', coords: [10, 51], income: 'high', region: 'Europe', pop: 83000000 },
|
|
{ name: 'France', coords: [2, 46], income: 'high', region: 'Europe', pop: 67000000 },
|
|
{ name: 'Japan', coords: [138, 36], income: 'high', region: 'Asia', pop: 126000000 },
|
|
{ name: 'Canada', coords: [-106, 56], income: 'high', region: 'Americas', pop: 38000000 },
|
|
{ name: 'Australia', coords: [133, -27], income: 'high', region: 'Oceania', pop: 25000000 },
|
|
{ name: 'South Korea', coords: [128, 37], income: 'high', region: 'Asia', pop: 52000000 },
|
|
{ name: 'Israel', coords: [35, 31], income: 'high', region: 'Middle East', pop: 9000000 },
|
|
{ name: 'Singapore', coords: [104, 1], income: 'high', region: 'Asia', pop: 5800000 },
|
|
|
|
// Upper-middle income - moderate vaccination
|
|
{ name: 'Brazil', coords: [-47, -14], income: 'upper-middle', region: 'Americas', pop: 212000000 },
|
|
{ name: 'China', coords: [105, 35], income: 'upper-middle', region: 'Asia', pop: 1400000000 },
|
|
{ name: 'Russia', coords: [105, 61], income: 'upper-middle', region: 'Europe', pop: 144000000 },
|
|
{ name: 'Mexico', coords: [-102, 23], income: 'upper-middle', region: 'Americas', pop: 128000000 },
|
|
{ name: 'Turkey', coords: [35, 39], income: 'upper-middle', region: 'Middle East', pop: 84000000 },
|
|
{ name: 'South Africa', coords: [25, -29], income: 'upper-middle', region: 'Africa', pop: 60000000 },
|
|
{ name: 'Argentina', coords: [-63, -38], income: 'upper-middle', region: 'Americas', pop: 45000000 },
|
|
{ name: 'Thailand', coords: [101, 15], income: 'upper-middle', region: 'Asia', pop: 70000000 },
|
|
|
|
// Lower-middle income - slower vaccination
|
|
{ name: 'India', coords: [78, 20], income: 'lower-middle', region: 'Asia', pop: 1380000000 },
|
|
{ name: 'Indonesia', coords: [113, -2], income: 'lower-middle', region: 'Asia', pop: 273000000 },
|
|
{ name: 'Egypt', coords: [30, 26], income: 'lower-middle', region: 'Africa', pop: 102000000 },
|
|
{ name: 'Pakistan', coords: [69, 30], income: 'lower-middle', region: 'Asia', pop: 220000000 },
|
|
{ name: 'Bangladesh', coords: [90, 24], income: 'lower-middle', region: 'Asia', pop: 164000000 },
|
|
{ name: 'Nigeria', coords: [8, 9], income: 'lower-middle', region: 'Africa', pop: 206000000 },
|
|
{ name: 'Philippines', coords: [123, 13], income: 'lower-middle', region: 'Asia', pop: 109000000 },
|
|
{ name: 'Vietnam', coords: [108, 14], income: 'lower-middle', region: 'Asia', pop: 97000000 },
|
|
{ name: 'Kenya', coords: [37, -1], income: 'lower-middle', region: 'Africa', pop: 53000000 },
|
|
|
|
// Low-income - limited vaccination (COVAX challenges)
|
|
{ name: 'Ethiopia', coords: [40, 8], income: 'low', region: 'Africa', pop: 115000000 },
|
|
{ name: 'Tanzania', coords: [35, -6], income: 'low', region: 'Africa', pop: 60000000 },
|
|
{ name: 'Uganda', coords: [32, 1], income: 'low', region: 'Africa', pop: 46000000 },
|
|
{ name: 'Mozambique', coords: [35, -18], income: 'low', region: 'Africa', pop: 31000000 },
|
|
{ name: 'Madagascar', coords: [47, -19], income: 'low', region: 'Africa', pop: 28000000 },
|
|
{ name: 'Malawi', coords: [34, -13], income: 'low', region: 'Africa', pop: 19000000 },
|
|
{ name: 'Chad', coords: [19, 15], income: 'low', region: 'Africa', pop: 16000000 },
|
|
{ name: 'Haiti', coords: [-72, 19], income: 'low', region: 'Americas', pop: 11000000 }
|
|
];
|
|
|
|
const features = countries.map((country, index) => {
|
|
// Coverage progression by income level
|
|
let coverageByYear;
|
|
let casesByYear;
|
|
|
|
if (country.income === 'high') {
|
|
// High-income: rapid vaccination, early access
|
|
coverageByYear = [2, 68, 82, 88]; // 2020: minimal, 2021: rapid rollout, 2022-2023: plateau
|
|
// Cases: high peak in 2020-2021, decline with vaccination
|
|
const peakCases = country.pop * 0.15; // 15% of population at peak
|
|
casesByYear = [peakCases * 0.7, peakCases, peakCases * 0.4, peakCases * 0.15];
|
|
} else if (country.income === 'upper-middle') {
|
|
// Upper-middle: moderate pace
|
|
coverageByYear = [1, 45, 68, 75];
|
|
const peakCases = country.pop * 0.12;
|
|
casesByYear = [peakCases * 0.6, peakCases, peakCases * 0.5, peakCases * 0.2];
|
|
} else if (country.income === 'lower-middle') {
|
|
// Lower-middle: slower rollout
|
|
coverageByYear = [0.5, 25, 48, 58];
|
|
const peakCases = country.pop * 0.10;
|
|
casesByYear = [peakCases * 0.5, peakCases, peakCases * 0.6, peakCases * 0.3];
|
|
} else {
|
|
// Low-income: COVAX challenges, minimal early access
|
|
coverageByYear = [0, 8, 18, 24];
|
|
const peakCases = country.pop * 0.08;
|
|
casesByYear = [peakCases * 0.4, peakCases, peakCases * 0.7, peakCases * 0.4];
|
|
}
|
|
|
|
return {
|
|
type: 'Feature',
|
|
id: index,
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: country.coords
|
|
},
|
|
properties: {
|
|
name: country.name,
|
|
income_level: country.income,
|
|
region: country.region,
|
|
population: country.pop,
|
|
|
|
// Current year data (for map display)
|
|
coverage: coverageByYear[currentYearIndex],
|
|
cases: Math.round(casesByYear[currentYearIndex]),
|
|
|
|
// Time series data (for charts)
|
|
years_array: JSON.stringify(YEARS),
|
|
coverage_array: JSON.stringify(coverageByYear),
|
|
cases_array: JSON.stringify(casesByYear.map(c => Math.round(c)))
|
|
}
|
|
};
|
|
});
|
|
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: features
|
|
};
|
|
}
|
|
|
|
// Update global statistics
|
|
function updateGlobalStats(data) {
|
|
const features = data.features;
|
|
|
|
// Calculate weighted average coverage
|
|
let totalPop = 0;
|
|
let weightedCoverage = 0;
|
|
let totalCases = 0;
|
|
|
|
features.forEach(f => {
|
|
const pop = f.properties.population;
|
|
const coverage = f.properties.coverage;
|
|
const cases = f.properties.cases;
|
|
|
|
totalPop += pop;
|
|
weightedCoverage += coverage * pop;
|
|
totalCases += cases;
|
|
});
|
|
|
|
const avgCoverage = (weightedCoverage / totalPop).toFixed(1);
|
|
|
|
document.getElementById('global-coverage').textContent = avgCoverage + '%';
|
|
document.getElementById('total-cases').textContent = (totalCases / 1000000).toFixed(1) + 'M';
|
|
}
|
|
|
|
// Update map for current year
|
|
function updateMap() {
|
|
const data = generateCovidData();
|
|
updateGlobalStats(data);
|
|
|
|
if (map.getSource('covid-data')) {
|
|
map.getSource('covid-data').setData(data);
|
|
}
|
|
|
|
document.getElementById('current-year').textContent = YEARS[currentYearIndex];
|
|
document.getElementById('year-slider').value = currentYearIndex;
|
|
}
|
|
|
|
// Map load handler
|
|
map.on('load', () => {
|
|
// Initialize data
|
|
const initialData = generateCovidData();
|
|
|
|
// Add source
|
|
map.addSource('covid-data', {
|
|
type: 'geojson',
|
|
data: initialData
|
|
});
|
|
|
|
// Create layer using LayerFactory
|
|
const factory = new LayerFactory(map);
|
|
|
|
const layer = factory.createCircleLayer({
|
|
id: 'covid-layer',
|
|
source: 'covid-data',
|
|
sizeProperty: 'population',
|
|
sizeRange: [6, 35],
|
|
colorProperty: 'coverage',
|
|
colorScale: 'coverage',
|
|
opacityRange: [0.85, 0.95]
|
|
});
|
|
|
|
map.addLayer(layer);
|
|
|
|
// Apply atmosphere
|
|
factory.applyGlobeAtmosphere({ theme: 'medical' });
|
|
|
|
// Update initial stats
|
|
updateGlobalStats(initialData);
|
|
|
|
// Setup dual-axis chart popups
|
|
// Dual-axis configuration from Chart.js cartesian axes documentation
|
|
|
|
// Click on country to pin/unpin popup
|
|
map.on('click', 'covid-layer', (e) => {
|
|
const feature = e.features[0];
|
|
const props = feature.properties;
|
|
|
|
// Check if this country already has a pinned popup
|
|
const existingPopupIndex = pinnedPopups.findIndex(p => p._countryName === props.name);
|
|
|
|
if (existingPopupIndex !== -1) {
|
|
// Country is pinned - remove it
|
|
const popup = pinnedPopups[existingPopupIndex];
|
|
popup.remove();
|
|
pinnedPopups.splice(existingPopupIndex, 1);
|
|
console.log('Unpinned:', props.name);
|
|
} else {
|
|
// Country not pinned - create and pin popup
|
|
const years = JSON.parse(props.years_array);
|
|
const coverage = JSON.parse(props.coverage_array);
|
|
const cases = JSON.parse(props.cases_array);
|
|
const canvasId = `chart-${chartCounter++}`;
|
|
|
|
const popup = new mapboxgl.Popup({
|
|
offset: 15,
|
|
closeButton: true,
|
|
closeOnClick: false
|
|
})
|
|
.setLngLat(feature.geometry.coordinates)
|
|
.setHTML(`
|
|
<div class="advanced-popup" style="cursor: pointer;">
|
|
<h3 style="margin-bottom: 8px;">${props.name}</h3>
|
|
<div class="pin-hint" style="font-size: 11px; color: #60a5fa; margin-bottom: 8px; font-weight: 600; text-align: center; padding: 4px 8px; background: rgba(96, 165, 250, 0.1); border-radius: 4px;">
|
|
📌 Pinned • Click country or popup to close
|
|
</div>
|
|
<canvas id="${canvasId}" width="420" height="280"></canvas>
|
|
</div>
|
|
`)
|
|
.addTo(map);
|
|
|
|
popup._isPinned = true;
|
|
popup._countryName = props.name;
|
|
pinnedPopups.push(popup);
|
|
|
|
// Add click handler to popup itself to allow closing by clicking popup
|
|
const popupElement = popup.getElement();
|
|
if (popupElement) {
|
|
popupElement.addEventListener('click', (event) => {
|
|
// Don't trigger if clicking the close button (let Mapbox handle that)
|
|
if (event.target.classList.contains('mapboxgl-popup-close-button')) {
|
|
return;
|
|
}
|
|
|
|
// Close this popup
|
|
const popupIndex = pinnedPopups.findIndex(p => p._countryName === props.name);
|
|
if (popupIndex !== -1) {
|
|
pinnedPopups.splice(popupIndex, 1);
|
|
}
|
|
popup.remove();
|
|
console.log('Unpinned via popup click:', props.name);
|
|
});
|
|
}
|
|
|
|
// Create chart
|
|
requestAnimationFrame(() => {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: years,
|
|
datasets: [
|
|
{
|
|
label: 'Vaccination Coverage (%)',
|
|
data: coverage,
|
|
borderColor: 'rgb(16, 185, 129)',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.15)',
|
|
yAxisID: 'y',
|
|
tension: 0.4,
|
|
fill: true,
|
|
pointRadius: 5,
|
|
pointHoverRadius: 7,
|
|
pointBackgroundColor: 'rgb(16, 185, 129)',
|
|
pointBorderColor: '#fff',
|
|
pointBorderWidth: 2,
|
|
borderWidth: 3
|
|
},
|
|
{
|
|
label: 'COVID-19 Cases',
|
|
data: cases,
|
|
borderColor: 'rgb(239, 68, 68)',
|
|
backgroundColor: 'rgba(239, 68, 68, 0.3)',
|
|
yAxisID: 'y1',
|
|
type: 'bar',
|
|
barThickness: 30,
|
|
borderWidth: 2
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
interaction: {
|
|
mode: 'index',
|
|
intersect: false
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: {
|
|
color: '#9ca3af',
|
|
font: { size: 12, weight: '600' }
|
|
},
|
|
grid: {
|
|
color: 'rgba(255, 255, 255, 0.05)',
|
|
drawBorder: false
|
|
}
|
|
},
|
|
y: {
|
|
type: 'linear',
|
|
position: 'left',
|
|
title: {
|
|
display: true,
|
|
text: 'Coverage (%)',
|
|
color: '#10b981',
|
|
font: { size: 13, weight: '700' }
|
|
},
|
|
min: 0,
|
|
max: 100,
|
|
ticks: {
|
|
color: '#10b981',
|
|
font: { size: 11, weight: '600' },
|
|
callback: function(value) {
|
|
return value + '%';
|
|
}
|
|
},
|
|
grid: {
|
|
color: 'rgba(16, 185, 129, 0.1)',
|
|
drawBorder: false
|
|
}
|
|
},
|
|
y1: {
|
|
type: 'linear',
|
|
position: 'right',
|
|
title: {
|
|
display: true,
|
|
text: 'Cases',
|
|
color: '#ef4444',
|
|
font: { size: 13, weight: '700' }
|
|
},
|
|
grid: {
|
|
drawOnChartArea: false
|
|
},
|
|
ticks: {
|
|
color: '#ef4444',
|
|
font: { size: 11, weight: '600' },
|
|
callback: function(value) {
|
|
if (value >= 1000000) {
|
|
return (value / 1000000).toFixed(1) + 'M';
|
|
} else if (value >= 1000) {
|
|
return (value / 1000).toFixed(0) + 'K';
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
plugins: {
|
|
title: {
|
|
display: true,
|
|
text: 'COVID-19 Vaccination Impact Timeline',
|
|
color: '#f3f4f6',
|
|
font: { size: 14, weight: '700' },
|
|
padding: { top: 5, bottom: 15 }
|
|
},
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: {
|
|
color: '#e5e7eb',
|
|
font: { size: 12, weight: '600' },
|
|
padding: 15,
|
|
usePointStyle: true
|
|
}
|
|
},
|
|
tooltip: {
|
|
backgroundColor: 'rgba(10, 14, 26, 0.95)',
|
|
titleColor: '#f3f4f6',
|
|
bodyColor: '#d1d5db',
|
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
|
borderWidth: 1,
|
|
padding: 12,
|
|
displayColors: true,
|
|
callbacks: {
|
|
label: function(context) {
|
|
let label = context.dataset.label || '';
|
|
if (label) {
|
|
label += ': ';
|
|
}
|
|
if (context.datasetIndex === 0) {
|
|
label += context.parsed.y.toFixed(1) + '%';
|
|
} else {
|
|
const value = context.parsed.y;
|
|
if (value >= 1000000) {
|
|
label += (value / 1000000).toFixed(2) + 'M';
|
|
} else if (value >= 1000) {
|
|
label += (value / 1000).toFixed(0) + 'K';
|
|
} else {
|
|
label += value;
|
|
}
|
|
}
|
|
return label;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log('Pinned:', props.name);
|
|
}
|
|
});
|
|
|
|
// Just change cursor on hover
|
|
map.on('mouseenter', 'covid-layer', () => {
|
|
map.getCanvas().style.cursor = 'pointer';
|
|
});
|
|
|
|
map.on('mouseleave', 'covid-layer', () => {
|
|
map.getCanvas().style.cursor = '';
|
|
});
|
|
});
|
|
|
|
// Timeline controls
|
|
const slider = document.getElementById('year-slider');
|
|
const playPauseBtn = document.getElementById('play-pause');
|
|
const resetBtn = document.getElementById('reset');
|
|
const loopCheckbox = document.getElementById('loop-checkbox');
|
|
|
|
slider.addEventListener('input', (e) => {
|
|
currentYearIndex = parseInt(e.target.value);
|
|
updateMap();
|
|
|
|
// Stop playing if user manually adjusts slider
|
|
if (isPlaying) {
|
|
stopPlaying();
|
|
}
|
|
});
|
|
|
|
playPauseBtn.addEventListener('click', () => {
|
|
if (isPlaying) {
|
|
stopPlaying();
|
|
} else {
|
|
startPlaying();
|
|
}
|
|
});
|
|
|
|
resetBtn.addEventListener('click', () => {
|
|
stopPlaying();
|
|
currentYearIndex = 0;
|
|
updateMap();
|
|
});
|
|
|
|
function startPlaying() {
|
|
isPlaying = true;
|
|
playPauseBtn.textContent = '⏸ Pause';
|
|
playPauseBtn.classList.add('playing');
|
|
|
|
playInterval = setInterval(() => {
|
|
currentYearIndex++;
|
|
|
|
if (currentYearIndex >= YEARS.length) {
|
|
if (loopCheckbox.checked) {
|
|
currentYearIndex = 0;
|
|
} else {
|
|
stopPlaying();
|
|
currentYearIndex = YEARS.length - 1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
updateMap();
|
|
}, 1500); // 1.5 seconds per year
|
|
}
|
|
|
|
function stopPlaying() {
|
|
isPlaying = false;
|
|
playPauseBtn.textContent = '▶ Play';
|
|
playPauseBtn.classList.remove('playing');
|
|
|
|
if (playInterval) {
|
|
clearInterval(playInterval);
|
|
playInterval = null;
|
|
}
|
|
}
|
|
|
|
// Globe rotation
|
|
let userInteracting = false;
|
|
map.on('mousedown', () => { userInteracting = true; });
|
|
map.on('mouseup', () => { userInteracting = false; });
|
|
|
|
function spinGlobe() {
|
|
if (!userInteracting && !isPlaying) {
|
|
const center = map.getCenter();
|
|
center.lng += 0.15;
|
|
map.setCenter(center);
|
|
}
|
|
requestAnimationFrame(spinGlobe);
|
|
}
|
|
// spinGlobe(); // Auto-rotation disabled
|
|
</script>
|
|
</body>
|
|
</html>
|