22 KiB
Technical Documentation: Measles Vaccination Timeline Visualization
Project Metadata
- Iteration: 1 (Foundation Level)
- Disease Focus: Measles (MCV1/MCV2 vaccines)
- Time Period: 2000-2023 (24 years)
- Geographic Coverage: 60 countries across 6 WHO regions
- Web Learning Source: https://docs.mapbox.com/mapbox-gl-js/example/popup-on-hover/
- Created: 2025-11-08
Architecture Overview
File Structure
vaccine_timeseries_1_measles/
├── index.html # Complete standalone visualization
├── README.md # Medical/epidemiological analysis
└── CLAUDE.md # Technical documentation (this file)
Dependencies
External Libraries:
- Mapbox GL JS v3.0.1 (CDN)
- Mapbox GL CSS v3.0.1 (CDN)
Shared Architecture:
import { MAPBOX_CONFIG } from '../../mapbox_test/shared/mapbox-config.js';
import { LayerFactory, COLOR_SCALES } from '../../mapbox_test/shared/layer-factory.js';
Mapbox Access Token:
- Managed through MAPBOX_CONFIG.accessToken
- Centralized configuration prevents token duplication
Web Learning Application
Research Assignment
URL: https://docs.mapbox.com/mapbox-gl-js/example/popup-on-hover/ Topic: Popup-on-hover interaction pattern Mission: Learn and apply mouseenter/mouseleave event pattern for country information display
Techniques Extracted
1. Single Popup Instance Pattern
Learning: Create popup instance once before event listeners to prevent flickering and reduce DOM manipulation overhead.
Implementation:
// Pattern from Mapbox popup-on-hover example:
// Create single popup instance before interactions to prevent flickering
const popup = new mapboxgl.Popup({
closeButton: false, // No close button for hover popups
closeOnClick: false // Don't close when clicking elsewhere
});
Why This Matters:
- Creating new popups on every hover causes visible flickering
- Single instance reuse is more performant
- Consistent behavior across all interactions
2. MouseEnter Event Handler
Learning: Use mouseenter event to show popup with feature data from event object.
Implementation:
map.on('mouseenter', 'vaccine-circles', (e) => {
// Change cursor to pointer (UX improvement)
map.getCanvas().style.cursor = 'pointer';
// Extract coordinates and properties from event
const coordinates = e.features[0].geometry.coordinates.slice();
const props = e.features[0].properties;
// Build HTML content from feature properties
const popupHTML = `
<div class="popup-title">${props.name}</div>
<div class="popup-stat">
<span class="popup-stat-label">Year:</span>
<span class="popup-stat-value">${props.year}</span>
</div>
<!-- Additional stats -->
`;
// Handle antimeridian crossing
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
// Reuse popup instance with new content
popup.setLngLat(coordinates)
.setHTML(popupHTML)
.addTo(map);
});
Key Details:
- Event object (e) contains features array with hovered feature
- .slice() creates coordinate copy to avoid mutation
- Antimeridian logic ensures correct popup positioning at ±180° longitude
- setLngLat() + setHTML() + addTo() pattern for popup display
3. MouseLeave Event Handler
Learning: Use mouseleave event to remove popup and reset cursor.
Implementation:
map.on('mouseleave', 'vaccine-circles', () => {
map.getCanvas().style.cursor = ''; // Reset cursor
popup.remove(); // Remove popup from map
});
Key Details:
- Simple cleanup: cursor reset + popup removal
- No need for null checks (Mapbox handles gracefully)
- Ensures clean state when mouse exits layer
4. Feature Identification
Learning: Enable generateId on source to allow feature-based event handling.
Implementation:
map.addSource('vaccine-data', {
type: 'geojson',
data: vaccineData,
generateId: true // Enable automatic feature ID generation
});
Why This Matters:
- Allows Mapbox to track individual features for hover events
- Required for reliable mouseenter/mouseleave detection
- Enables feature-state styling (not used in this iteration)
5. Cursor Styling
Learning: Change cursor to pointer on hover for clear interaction affordance.
Implementation:
// On mouseenter
map.getCanvas().style.cursor = 'pointer';
// On mouseleave
map.getCanvas().style.cursor = '';
UX Impact:
- Clear visual feedback that element is interactive
- Standard web convention for clickable/hoverable elements
Pattern Verification
Code Comments: All hover-related code includes documentation:
// Pattern from Mapbox popup-on-hover example
This ensures traceability to the web learning source and helps future developers understand the pattern origin.
Data Architecture
GeoJSON Structure
Format: Flattened feature collection (one feature per country-year)
Feature Count: 1,440 features
- 60 countries
- 24 years per country (2000-2023)
- 60 × 24 = 1,440
Advantages of Flattened Approach:
- Simple filtering:
['==', ['get', 'year'], selectedYear]shows only relevant features - No server required: All data embedded in client-side JavaScript
- Fast rendering: Mapbox filters 1,440 features to ~60 visible instantly
- Easy debugging: Each feature is self-contained
Feature Schema:
{
type: "Feature",
geometry: {
type: "Point",
coordinates: [longitude, latitude]
},
properties: {
// Identification
name: "Nigeria", // Country name
iso3: "NGA", // ISO 3166-1 alpha-3 code
year: 2015, // Data year
// Context
region: "AFRO", // WHO region
income_level: "lower-middle", // World Bank classification
population: 182202000, // Population in data year
// Vaccination Coverage
coverage_dose1: 52, // MCV1 coverage percentage
coverage_dose2: 35, // MCV2 coverage percentage
// Disease Burden
cases: 45000, // Estimated measles cases
deaths: 850, // Estimated measles deaths
// Time Series (for future chart use)
years_array: "[2000,2001,...,2023]",
coverage1_array: "[30,35,...,68]",
coverage2_array: "[15,20,...,45]",
cases_array: "[150000,140000,...,15000]",
deaths_array: "[4500,4200,...,450]"
}
}
Data Generation Logic
Realistic Modeling:
- Base Coverage: Each country assigned realistic baseline from WHO data
- Temporal Trends:
- 15% improvement over 24 years (global trend)
- COVID-19 dip: 8% drop in 2020-2022
- Partial recovery by 2023
- Regional Factors:
- AFRO: 0.9× multiplier (lower coverage)
- EURO: 1.05× multiplier (higher coverage)
- EMRO (low-income): 0.85× multiplier (conflict/access issues)
- Random Variation: ±2.5% per year to simulate real-world fluctuations
Case Calculation:
const herdImmunityThreshold = 95;
const coverageGap = Math.max(0, herdImmunityThreshold - coverage1);
const baseCases = (population / 100000) * 100; // 100 cases per 100k baseline
const cases = baseCases * (1 + coverageGap / 10) * randomFactor;
Death Calculation:
const cfr = income === "low" ? 0.03 : // 3% case fatality rate
income === "lower-middle" ? 0.02 : // 2% CFR
0.01; // 1% CFR (high-income)
const deaths = cases * cfr * randomFactor;
Visual Encoding
Color Scale (Coverage-Based)
Expression:
const coverageColorExpression = [
'interpolate',
['linear'],
['get', 'coverage_dose1'],
0, '#ef4444', // < 50: Red (critical)
50, '#f59e0b', // 50-70: Orange (low)
70, '#eab308', // 70-85: Yellow (medium)
85, '#84cc16', // 85-95: Lime (good)
95, '#22c55e' // > 95: Green (excellent)
];
Rationale:
- Red signals danger (below herd immunity, endemic transmission)
- Yellow indicates transition zone (outbreaks likely)
- Green shows success (herd immunity achieved)
- Matches epidemiological thresholds for measles control
Size Scale (Population-Based)
Expression:
const populationSizeExpression = [
'interpolate',
['exponential', 0.5], // Square root scaling for better visual distribution
['get', 'population'],
1000000, 8, // 1M people → 8px radius
50000000, 18, // 50M people → 18px radius
200000000, 30, // 200M people → 30px radius
1000000000, 45 // 1B people → 45px radius
];
Rationale:
- Exponential 0.5 (square root) prevents India/China from overwhelming map
- Larger circles = more people affected by coverage gaps
- Emphasizes importance of high-population countries
Layer Stack
1. Glow Layer (bottom):
{
id: 'vaccine-glow',
type: 'circle',
paint: {
'circle-radius': ['+', populationSizeExpression, 8],
'circle-opacity': 0.2,
'circle-blur': 1
}
}
- Creates soft halo effect
- Enhances visibility against dark background
- Radius = main circle + 8px
2. Main Circle Layer (top):
{
id: 'vaccine-circles',
type: 'circle',
paint: {
'circle-radius': populationSizeExpression,
'circle-color': coverageColorExpression,
'circle-opacity': 0.8,
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff',
'circle-stroke-opacity': 0.4
}
}
- Primary visual element
- White stroke provides definition
- 0.8 opacity allows slight overlap visibility
Timeline Control Implementation
HTML5 Range Input
<input type="range" min="2000" max="2023" value="2015"
class="timeline-slider" id="timelineSlider">
Attributes:
- min/max: Year range bounds
- value: Initial year (2015 - midpoint)
- Step: Default 1 (whole years)
Filter Update Logic
slider.addEventListener('input', (e) => {
const selectedYear = parseInt(e.target.value);
yearDisplay.textContent = selectedYear;
// Update both layers to show only selected year
map.setFilter('vaccine-circles', ['==', ['get', 'year'], selectedYear]);
map.setFilter('vaccine-glow', ['==', ['get', 'year'], selectedYear]);
});
Key Techniques:
inputevent fires continuously during drag (vs.changewhich fires on release)- setFilter() is highly performant (GPU-accelerated)
- Both layers must be filtered to maintain visual consistency
Custom Slider Styling
WebKit/Blink Browsers:
.timeline-slider::-webkit-slider-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
box-shadow: 0 2px 8px rgba(96, 165, 250, 0.4);
}
Firefox:
.timeline-slider::-moz-range-thumb {
/* Same properties */
}
Hover Effect:
::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
Globe Configuration
Projection & Initial View
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/dark-v11',
projection: 'globe', // 3D globe (vs. mercator)
center: [20, 20], // Africa-centered
zoom: 1.5 // Show full globe
});
Atmosphere (Dark Theme)
map.setFog({
color: 'rgb(10, 14, 39)', // Atmosphere base color
'high-color': 'rgb(30, 41, 82)', // Color at horizon
'horizon-blend': 0.02, // Blend amount
'space-color': 'rgb(5, 7, 20)', // Background space color
'star-intensity': 0.6 // Star brightness (0-1)
});
Visual Effect:
- Deep blue-black space background
- Subtle atmosphere glow around globe limb
- Visible stars for depth perception
- Matches dark UI theme throughout
Globe Rotation Animation
let userInteracting = false;
const spinGlobe = () => {
if (!userInteracting) {
map.easeTo({
center: [map.getCenter().lng + 0.1, map.getCenter().lat],
duration: 1000,
easing: (t) => t // Linear easing
});
}
};
// Pause rotation during user interaction
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; });
setInterval(spinGlobe, 1000); // Rotate every second
Behavior:
- Gentle eastward rotation when idle
- Stops immediately when user interacts
- Resumes after interaction completes
- Creates "living" visualization feel
Popup Styling
Dark Theme Integration
.mapboxgl-popup-content {
background: rgba(15, 23, 42, 0.98);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(96, 165, 250, 0.3);
}
Design Choices:
- Near-opaque background (0.98 alpha) for readability
- Blue border matches UI accent color
- Consistent border radius (8px) with control panels
- Deep shadow for depth against dark background
Content Structure
<div class="popup-title">Nigeria</div>
<div class="popup-stat">
<span class="popup-stat-label">Year:</span>
<span class="popup-stat-value">2015</span>
</div>
<div class="popup-divider"></div>
<!-- More stats -->
Typography:
- Title: 16px bold, blue accent (#60a5fa)
- Labels: 13px, muted gray (#94a3b8)
- Values: 13px bold, bright white (#e0e6ed)
- Dividers separate logical groups
Performance Optimizations
1. Filter-Based Rendering
- Only 60 features rendered at a time (out of 1,440)
- GPU-accelerated filtering via Mapbox expressions
- No JavaScript loops for visibility control
2. Single Popup Instance
- One popup created, reused for all hovers
- Avoids repeated DOM creation/destruction
- Prevents memory leaks from orphaned popup elements
3. Embedded Data
- All 1,440 features in client-side GeoJSON
- No server requests after initial load
- Instant year transitions
4. Event Debouncing
- Globe rotation uses 1-second interval (not requestAnimationFrame)
- Reduces CPU usage when idle
- Sufficient for gentle ambient motion
5. Vector Rendering
- Mapbox GL uses WebGL for all rendering
- Scales smoothly at any zoom level
- Efficient on retina displays
Browser Compatibility
Supported:
- Chrome/Edge 79+ (Chromium)
- Firefox 78+
- Safari 14+
- Opera 66+
Requirements:
- WebGL support
- ES6 modules (import/export)
- CSS Grid and Flexbox
- HTML5 range input
Not Supported:
- Internet Explorer (no WebGL 2.0)
- Very old mobile browsers
Code Quality
ES6 Module Usage
import { MAPBOX_CONFIG } from '../../mapbox_test/shared/mapbox-config.js';
import { LayerFactory, COLOR_SCALES } from '../../mapbox_test/shared/layer-factory.js';
Benefits:
- Shared configuration across all visualizations
- Centralized token management
- Reusable layer factory patterns
- Easier maintenance
Commenting Strategy
Pattern Attribution:
// Pattern from Mapbox popup-on-hover example:
// Create single popup instance before interactions to prevent flickering
const popup = new mapboxgl.Popup({...});
Complex Logic:
// Ensure popup appears at correct location if coordinates cross antimeridian
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
Section Headers:
// ===== TIMELINE CONTROL =====
// ===== GLOBE CONFIGURATION =====
Variable Naming
Descriptive Names:
coverageColorExpression(notcolors)populationSizeExpression(notsizes)selectedYear(notyearory)
Consistent Prefixes:
map*for Mapbox objects (mapLayer, mapSource)popup*for popup-related variablesvaccine*for data objects
Testing Recommendations
Manual Testing Checklist
-
Timeline Slider:
- Drag slider smoothly through all years
- Year display updates correctly
- Circles appear/disappear as expected
- No console errors during rapid sliding
-
Hover Popups:
- Popup appears on mouseenter
- Popup disappears on mouseleave
- Cursor changes to pointer on hover
- No flickering when moving between circles
- Popup content matches hovered country
- Popup positioned correctly (no cutoff)
-
Globe Interaction:
- Globe rotates when idle
- Rotation stops on mousedown
- Can drag/rotate/zoom smoothly
- Rotation resumes after interaction
- No lag or stuttering
-
Data Accuracy:
- Countries appear in correct locations
- Coverage colors match legend
- Population sizes appear proportional
- All 60 countries visible in each year
- No duplicate circles in same location
-
Responsive Behavior:
- Layout works on desktop (1920×1080)
- Controls readable on laptop (1366×768)
- Map fills entire viewport
- Legend and controls don't overlap
Edge Cases to Test
-
Antimeridian Crossing:
- Hover countries near ±180° longitude (Fiji, Russia)
- Verify popup appears on correct side
-
Overlapping Circles:
- Hover countries with similar coordinates
- Verify correct country data shown
-
Rapid Interaction:
- Quickly move mouse across many circles
- Verify no orphaned popups or stuck cursors
-
Year Extremes:
- Test year 2000 (earliest)
- Test year 2023 (latest)
- Verify data exists for all countries
Future Enhancement Roadmap
Iteration 2 (Intermediate)
Auto-Play Feature:
- Play/pause button
- Configurable speed (1-5 seconds per year)
- Loop option
Trend Indicators:
- Up/down arrows showing coverage change vs. previous year
- Color-coded arrows (green = improvement, red = decline)
Regional Filtering:
- Buttons to filter by WHO region (AFRO, EURO, etc.)
- "Show All" option to reset
Iteration 3 (Advanced)
Country Detail Panels:
- Click country to open side panel
- Chart.js time series graph using stored arrays
- Show MCV1/MCV2 coverage trends
- Overlay cases/deaths on secondary axis
Outbreak Event Markers:
- Special markers for major outbreaks (>1000 cases)
- Animated pulse effect
- Popup shows outbreak details
Comparison Mode:
- Split screen: two years side-by-side
- Difference view: color by coverage change
- Highlight countries with largest shifts
Iteration 4 (Expert)
Herd Immunity Overlay:
- Threshold line at 95% coverage
- Countries below threshold highlighted with warning indicator
- Vulnerability score calculation
Predictive Modeling:
- Machine learning model for outbreak prediction
- Risk heatmap based on coverage gaps + recent trends
- Scenario planning: "What if coverage drops 5%?"
Live Data Integration:
- WHO/UNICEF API connection
- Real-time updates (quarterly)
- Data refresh button
- Version/timestamp display
Advanced Interactions:
- 3D bar charts rising from country locations (Deck.gl)
- Animated transitions between years (smooth morphing)
- VR mode for immersive exploration
- Export to video (year-by-year animation)
Lessons Learned
What Worked Well
-
Flattened Data Structure:
- Simple filtering logic
- Fast rendering
- Easy to debug
-
Single Popup Pattern:
- No flickering issues
- Smooth user experience
- Learned directly from Mapbox example
-
Dark Theme:
- Reduces eye strain
- Looks professional
- Matches scientific visualization conventions
-
Shared Architecture:
- Reusing MAPBOX_CONFIG saved time
- Consistent patterns across project
Challenges Encountered
-
Antimeridian Handling:
- Initially forgot coordinate correction logic
- Mapbox example showed the solution
- Now implemented correctly
-
Color Scale Calibration:
- Needed multiple iterations to find readable colors
- Final palette balances visibility with semantic meaning
-
Population Sizing:
- Linear scaling made India/China too dominant
- Exponential 0.5 (square root) provided better balance
Key Takeaways
-
Web research is invaluable:
- Official examples show best practices
- Following established patterns prevents bugs
- Documentation is key to future maintenance
-
Data quality matters:
- Realistic data generation creates credible visualization
- Epidemiological parameters add authenticity
- Time investment in data pays off in insights
-
Progressive complexity:
- Foundation iteration should be simple but complete
- Each feature should work perfectly before adding more
- Future iterations can build on solid base
Maintenance Notes
Updating Mapbox Version
If updating to newer Mapbox GL JS:
- Check changelog for breaking changes
- Update CDN links in HTML
- Test popup API (may change)
- Verify globe projection compatibility
- Test setFilter() behavior
Updating Data
To refresh with real WHO data:
- Download WUENIC estimates (Excel format)
- Parse to extract MCV1/MCV2 coverage by country/year
- Join with WHO measles surveillance data for cases/deaths
- Replace
generateMeaslesData()function with parser - Maintain same property schema
Adding Countries
To add more countries:
- Look up lat/lng coordinates
- Determine WHO region and income level
- Find baseline coverage from WUENIC
- Add to countries array in
generateMeaslesData() - Test for overlap with existing circles
Attribution
Web Learning:
- Source: Mapbox GL JS Examples
- URL: https://docs.mapbox.com/mapbox-gl-js/example/popup-on-hover/
- Pattern: Hover popup with mouseenter/mouseleave events
- Applied: Country information display on hover
Data Sources:
- WHO/UNICEF Estimates of National Immunization Coverage (WUENIC)
- WHO Measles & Rubella Surveillance Data
- World Bank Development Indicators
Technologies:
- Mapbox GL JS v3.0.1
- Shared architecture from /mapbox_test/shared/
License: This visualization is created for educational and demonstration purposes. Data is synthetically generated based on real-world parameters. For production use, please obtain authoritative data from WHO and national health agencies.
Created: 2025-11-08 Iteration: 1 (Foundation) Status: Complete and functional Next Iteration: Add auto-play timeline animation