# CLAUDE.md - COVID-19 Vaccination Timeline Technical Guide ## Iteration 3: Advanced Dual-Axis Chart.js Implementation **Iteration Level**: Advanced **Disease Focus**: COVID-19 (2020-2023) **Key Innovation**: Dual-axis Chart.js charts with cartesian axes configuration **Web Learning Source**: https://www.chartjs.org/docs/latest/axes/cartesian/ --- ## Web Learning Integration ### Assigned Research Task **URL**: https://www.chartjs.org/docs/latest/axes/cartesian/ **Topic**: Chart.js dual-axis configuration (cartesian axes) **Mission**: Fetch URL, learn dual y-axis configuration, implement coverage vs cases on separate axes ### Key Techniques Learned From the Chart.js cartesian axes documentation: 1. **Dataset-to-Axis Binding** ```javascript // Bind dataset to specific axis using yAxisID datasets: [ { label: 'Vaccination Coverage (%)', yAxisID: 'y', // Binds to left axis // ... }, { label: 'COVID-19 Cases', yAxisID: 'y1', // Binds to right axis // ... } ] ``` **Documentation Quote**: "The properties `dataset.xAxisID` or `dataset.yAxisID` have to match to `scales` property." 2. **Axis Positioning** ```javascript scales: { y: { position: 'left' // Coverage on left }, y1: { position: 'right' // Cases on right } } ``` Supported positions: `'top'`, `'left'`, `'bottom'`, `'right'`, `'center'` 3. **Explicit Type Declaration** ```javascript y: { type: 'linear', // Must explicitly declare type // ... } ``` **Documentation Quote**: "When adding new axes, it is important to ensure that you specify the type of the new axes as default types are not used in this case." 4. **Scale Configuration for Different Ranges** ```javascript y: { type: 'linear', min: 0, max: 100 // Fixed range for percentages }, y1: { type: 'linear' // Dynamic range for case counts } ``` ### Application to COVID-19 Visualization **Coverage Axis (Left, y)**: - Fixed scale: 0-100% - Green color scheme - Percentage formatting in ticks - Represents vaccination progress **Cases Axis (Right, y1)**: - Dynamic scale based on data - Red color scheme - Abbreviated formatting (M, K) - Represents COVID-19 case counts - Grid disabled (`drawOnChartArea: false`) to prevent overlap --- ## Architecture Overview ### File Structure ``` vaccine_timeseries_3_covid/ ├── index.html # 720 lines - Complete standalone visualization ├── README.md # User-facing COVID-19 equity analysis └── CLAUDE.md # This file - Technical implementation guide ``` ### Technology Stack | Component | Version | Purpose | |-----------|---------|---------| | Mapbox GL JS | v3.0.1 | Globe visualization with projection | | Chart.js | v4.4.0 | Dual-axis time series charts | | ES6 Modules | Native | Shared architecture imports | | HTML5 Canvas | Native | Chart rendering target | ### Shared Architecture Integration ```javascript import { MAPBOX_CONFIG } from '../../mapbox_test/shared/mapbox-config.js'; import { LayerFactory } from '../../mapbox_test/shared/layer-factory.js'; ``` **Benefits**: - Centralized Mapbox token management - Consistent layer styling across projects - Reusable atmosphere and color scale presets - Token validation and error handling --- ## Data Model Implementation ### COVID-19 Vaccination Data Structure **Income-Based Stratification**: ```javascript // High-income countries - rapid vaccination if (country.income === 'high') { coverageByYear = [2, 68, 82, 88]; // 2020, 2021, 2022, 2023 const peakCases = country.pop * 0.15; // 15% at peak casesByYear = [peakCases * 0.7, peakCases, peakCases * 0.4, peakCases * 0.15]; } // Low-income countries - COVAX challenges else { coverageByYear = [0, 8, 18, 24]; // Minimal early access const peakCases = country.pop * 0.08; casesByYear = [peakCases * 0.4, peakCases, peakCases * 0.7, peakCases * 0.4]; } ``` ### GeoJSON Feature Properties Each country feature contains: ```javascript properties: { name: 'United States', income_level: 'high', // Income classification region: 'Americas', // WHO region population: 331000000, // For sizing circles // Current year values (updated by slider) coverage: 88, // Current year coverage % cases: 12000000, // Current year cases // Time series arrays (for Chart.js) years_array: '[2020, 2021, 2022, 2023]', coverage_array: '[2, 68, 82, 88]', cases_array: '[23175000, 49650000, 19860000, 7447500]' } ``` **Array Serialization**: - Stored as JSON strings in properties - Parsed when creating charts - Enables complete time series in single feature --- ## Dual-Axis Chart Implementation ### Chart Lifecycle Management **Critical Performance Pattern**: ```javascript let activeChart = null; // Track current chart instance let chartCounter = 0; // Generate unique canvas IDs let popupTimeout = null; // Debounce popup creation // Destroy previous chart before creating new one if (activeChart) { activeChart.destroy(); // Prevents memory leaks activeChart = null; } // Delay popup to prevent flickering popupTimeout = setTimeout(() => { // Create new chart const canvasId = `chart-${chartCounter++}`; // ... popup and chart creation }, 200); // 200ms debounce ``` **Why This Matters**: - Chart.js instances consume memory and canvas contexts - Without cleanup, hovering over many countries causes memory bloat - Debouncing prevents chart creation when quickly moving mouse - Unique IDs prevent DOM conflicts ### Chart Configuration Deep Dive **Complete Dual-Axis Setup**: ```javascript activeChart = new Chart(ctx, { type: 'line', // Base type, but datasets can override data: { labels: [2020, 2021, 2022, 2023], datasets: [ { label: 'Vaccination Coverage (%)', data: [2, 68, 82, 88], borderColor: 'rgb(16, 185, 129)', // Green backgroundColor: 'rgba(16, 185, 129, 0.15)', yAxisID: 'y', // LEFT AXIS tension: 0.4, // Smooth curves fill: true, // Area fill pointRadius: 5, pointHoverRadius: 7, pointBackgroundColor: 'rgb(16, 185, 129)', pointBorderColor: '#fff', pointBorderWidth: 2, borderWidth: 3 }, { label: 'COVID-19 Cases', data: [23175000, 49650000, 19860000, 7447500], borderColor: 'rgb(239, 68, 68)', // Red backgroundColor: 'rgba(239, 68, 68, 0.3)', yAxisID: 'y1', // RIGHT AXIS type: 'bar', // Override to bar barThickness: 30, borderWidth: 2 } ] }, options: { responsive: true, maintainAspectRatio: true, interaction: { mode: 'index', // Show all datasets at x position intersect: false // Don't require exact point hover }, scales: { x: { ticks: { color: '#9ca3af', font: { size: 12, weight: '600' } }, grid: { color: 'rgba(255, 255, 255, 0.05)', drawBorder: false } }, // LEFT Y-AXIS: Coverage (0-100%) 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 } }, // RIGHT Y-AXIS: Cases (dynamic range) y1: { type: 'linear', position: 'right', title: { display: true, text: 'Cases', color: '#ef4444', font: { size: 13, weight: '700' } }, grid: { drawOnChartArea: false // Key: prevents grid overlap }, 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' } }, legend: { position: 'bottom', labels: { color: '#e5e7eb', font: { size: 12, weight: '600' }, usePointStyle: true } }, tooltip: { backgroundColor: 'rgba(10, 14, 26, 0.95)', callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) label += ': '; if (context.datasetIndex === 0) { // Coverage label += context.parsed.y.toFixed(1) + '%'; } else { // Cases 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; } } } } } }); ``` **Key Configuration Choices**: | Setting | Value | Rationale | |---------|-------|-----------| | `drawOnChartArea: false` on y1 | Disables right axis grid | Prevents visual clutter from overlapping grids | | `interaction.mode: 'index'` | Show all datasets | User sees both coverage and cases for same year | | `interaction.intersect: false` | No exact hover needed | Better UX, easier to trigger tooltips | | `ticks.callback` on y1 | Abbreviate numbers | Large case counts (millions) need compact display | | `type: 'bar'` on dataset | Override base type | Visual contrast: line for trend, bars for magnitude | --- ## Timeline Control System ### State Management ```javascript const YEARS = [2020, 2021, 2022, 2023]; let currentYearIndex = 3; // Start at 2023 (latest) let isPlaying = false; // Animation state let playInterval = null; // setInterval reference ``` ### Play/Pause Animation ```javascript function startPlaying() { isPlaying = true; playPauseBtn.textContent = '⏸ Pause'; playPauseBtn.classList.add('playing'); playInterval = setInterval(() => { currentYearIndex++; if (currentYearIndex >= YEARS.length) { if (loopCheckbox.checked) { currentYearIndex = 0; // Restart from 2020 } else { stopPlaying(); currentYearIndex = YEARS.length - 1; // Stay at 2023 return; } } updateMap(); // Regenerate data and update display }, 1500); // 1.5 seconds per year } function stopPlaying() { isPlaying = false; playPauseBtn.textContent = '▶ Play'; playPauseBtn.classList.remove('playing'); if (playInterval) { clearInterval(playInterval); playInterval = null; } } ``` ### Map Update Function ```javascript function updateMap() { // Regenerate data for current year const data = generateCovidData(); // Uses currentYearIndex // Update global statistics updateGlobalStats(data); // Update map source if (map.getSource('covid-data')) { map.getSource('covid-data').setData(data); } // Update UI document.getElementById('current-year').textContent = YEARS[currentYearIndex]; document.getElementById('year-slider').value = currentYearIndex; } ``` **Update Triggers**: - Slider input (manual year selection) - Play animation interval (auto-advance) - Reset button (jump to 2020) --- ## Global Statistics Calculation ### Weighted Average Implementation ```javascript function updateGlobalStats(data) { const features = data.features; 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; // Weight by population totalCases += cases; }); const avgCoverage = (weightedCoverage / totalPop).toFixed(1); document.getElementById('global-coverage').textContent = avgCoverage + '%'; document.getElementById('total-cases').textContent = (totalCases / 1000000).toFixed(1) + 'M'; } ``` **Why Weighted Average?**: - Simple average would treat all countries equally - China (1.4B people) and Singapore (5.8M) would have equal weight - Weighted average reflects true global population coverage - More accurate representation of worldwide vaccination status --- ## Popup and Event Handling ### Debounced Popup Creation ```javascript map.on('mouseenter', 'covid-layer', (e) => { map.getCanvas().style.cursor = 'pointer'; // Clear any pending popup timeout if (popupTimeout) { clearTimeout(popupTimeout); } // 200ms delay prevents flickering popupTimeout = setTimeout(() => { // Destroy previous chart if (activeChart) { activeChart.destroy(); activeChart = null; } const feature = e.features[0]; const props = feature.properties; // Parse time series data const years = JSON.parse(props.years_array); const coverage = JSON.parse(props.coverage_array); const cases = JSON.parse(props.cases_array); // Generate unique canvas ID const canvasId = `chart-${chartCounter++}`; // Create popup with canvas const popup = new mapboxgl.Popup({ offset: 15 }) .setLngLat(feature.geometry.coordinates) .setHTML(`