/** * Layer Factory for Mapbox Globe Visualizations * * Creates optimized, best-practice layers for vaccine data visualization * using Point geometries and circle layers with data-driven styling. */ /** * Color scale presets for different metrics */ export const COLOR_SCALES = { // Coverage: Red (low) → Green (high) coverage: { type: 'sequential', domain: [0, 100], colors: ['#d73027', '#fc8d59', '#fee090', '#e0f3f8', '#91bfdb', '#4575b4'], stops: [0, 20, 40, 60, 80, 100] }, // Coverage (reversed): Green (high) → Red (low) coverageReverse: { type: 'sequential', domain: [0, 100], colors: ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850'], stops: [0, 20, 40, 60, 80, 100] }, // Diverging: Green (good) ← Gray (neutral) → Red (bad) diverging: { type: 'diverging', domain: [0, 100], colors: ['#d73027', '#fc8d59', '#fee08b', '#ffffbf', '#d9ef8b', '#91cf60', '#1a9850'], stops: [0, 16.67, 33.33, 50, 66.67, 83.33, 100] }, // Purple gradient (for HPV) purple: { type: 'sequential', domain: [0, 100], colors: ['#4a0e4e', '#6b1b6b', '#8e2a8e', '#b366ff', '#d499ff', '#ebccff'], stops: [0, 20, 40, 60, 80, 100] }, // Blue-Orange diverging blueOrange: { type: 'diverging', domain: [0, 100], colors: ['#313695', '#4575b4', '#74add1', '#fee090', '#fdae61', '#f46d43', '#a50026'], stops: [0, 16.67, 33.33, 50, 66.67, 83.33, 100] } }; /** * Layer Factory Class */ export class LayerFactory { constructor(map) { this.map = map; } /** * Create a circle layer with best practices * @param {Object} config - Layer configuration * @returns {Object} Mapbox layer specification */ createCircleLayer(config) { const { id, source, sizeProperty = 'population', sizeRange = [5, 30], colorProperty = 'coverage', colorScale = 'coverage', opacityRange = [0.8, 0.95], filter = null } = config; const layer = { id: id, type: 'circle', source: source, paint: { // Size with zoom-responsive scaling 'circle-radius': this.createSizeExpression(sizeProperty, sizeRange), // Color using specified scale 'circle-color': this.createColorExpression(colorProperty, colorScale), // Opacity with zoom adjustment 'circle-opacity': [ 'interpolate', ['linear'], ['zoom'], 1, opacityRange[0], 4, (opacityRange[0] + opacityRange[1]) / 2, 8, opacityRange[1] ], // Stroke for definition 'circle-stroke-width': [ 'interpolate', ['linear'], ['zoom'], 1, 0.5, 4, 1, 8, 2 ], 'circle-stroke-color': '#ffffff', 'circle-stroke-opacity': 0.6, // Subtle blur at low zoom for performance 'circle-blur': [ 'interpolate', ['linear'], ['zoom'], 1, 0.15, 4, 0.05, 8, 0 ], // Pitch alignment for globe 'circle-pitch-alignment': 'map', 'circle-pitch-scale': 'map' } }; // Only add filter if it's provided and not null if (filter) { layer.filter = filter; } return layer; } /** * Create size expression with zoom responsiveness * @param {string} property - Property to use for sizing * @param {Array} range - [minSize, maxSize] * @returns {Array} Mapbox expression */ createSizeExpression(property, range) { const [minSize, maxSize] = range; return [ 'interpolate', ['linear'], ['zoom'], // At low zoom (globe view) 1, [ 'interpolate', ['linear'], // Use coalesce to handle null/undefined values ['coalesce', ['get', property], 0], 0, minSize * 0.6, 100000000, maxSize * 0.6 ], // At medium zoom 4, [ 'interpolate', ['linear'], ['coalesce', ['get', property], 0], 0, minSize, 100000000, maxSize ], // At high zoom 8, [ 'interpolate', ['linear'], ['coalesce', ['get', property], 0], 0, minSize * 1.5, 100000000, maxSize * 1.5 ] ]; } /** * Create color expression from scale preset * @param {string} property - Property to use for coloring * @param {string} scaleName - Name of color scale from COLOR_SCALES * @returns {Array} Mapbox expression */ createColorExpression(property, scaleName) { const scale = COLOR_SCALES[scaleName] || COLOR_SCALES.coverage; const expression = [ 'interpolate', ['linear'], // Use coalesce to handle null/undefined values - defaults to 0 ['coalesce', ['get', property], 0] ]; // Add stops and colors scale.stops.forEach((stop, index) => { expression.push(stop, scale.colors[index]); }); return expression; } /** * Create a custom color expression with specific stops * @param {string} property - Property to color by * @param {Array} stops - Array of [value, color] pairs * @returns {Array} Mapbox expression */ createCustomColorExpression(property, stops) { const expression = [ 'interpolate', ['linear'], ['get', property] ]; stops.forEach(([value, color]) => { expression.push(value, color); }); return expression; } /** * Create a step color expression (discrete ranges) * @param {string} property - Property to color by * @param {Array} steps - Array of [threshold, color] pairs * @param {string} defaultColor - Default color * @returns {Array} Mapbox expression */ createStepColorExpression(property, steps, defaultColor = '#cccccc') { const expression = [ 'step', ['get', property], defaultColor ]; steps.forEach(([threshold, color]) => { expression.push(threshold, color); }); return expression; } /** * Create a hover effect layer (larger, semi-transparent circles) * @param {string} sourceId - Source ID * @param {string} baseLayerId - Base layer ID to match * @returns {Object} Mapbox layer specification */ createHoverLayer(sourceId, baseLayerId) { return { id: `${baseLayerId}-hover`, type: 'circle', source: sourceId, paint: { 'circle-radius': [ 'case', ['boolean', ['feature-state', 'hover'], false], 35, // Larger when hovered 0 // Hidden otherwise ], 'circle-color': '#ffffff', 'circle-opacity': 0.3, 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff', 'circle-stroke-opacity': 0.8 } }; } /** * Create a pulse animation layer * @param {string} sourceId - Source ID * @param {Object} config - Animation configuration * @returns {Object} Mapbox layer specification */ createPulseLayer(sourceId, config = {}) { const { id = 'pulse-layer', sizeMultiplier = 1.5, color = 'rgba(74, 222, 128, 0.4)', filter = null } = config; return { id: id, type: 'circle', source: sourceId, filter: filter, paint: { 'circle-radius': [ 'interpolate', ['linear'], ['zoom'], 1, ['*', ['get', 'baseRadius'], sizeMultiplier * 0.8], 8, ['*', ['get', 'baseRadius'], sizeMultiplier * 1.2] ], 'circle-color': color, 'circle-opacity': 0.5, 'circle-blur': 1 } }; } /** * Apply best-practice globe atmosphere * @param {Object} config - Atmosphere configuration */ applyGlobeAtmosphere(config = {}) { const { theme = 'default', customConfig = null } = config; const atmospherePresets = { default: { color: 'rgba(186, 210, 235, 0.9)', 'high-color': 'rgba(36, 92, 223, 0.5)', 'horizon-blend': 0.02, 'space-color': 'rgba(11, 11, 25, 1)', 'star-intensity': 0.6 }, dark: { color: 'rgba(15, 20, 35, 0.95)', 'high-color': 'rgba(40, 60, 100, 0.6)', 'horizon-blend': 0.05, 'space-color': 'rgba(5, 8, 15, 1)', 'star-intensity': 0.85 }, medical: { color: 'rgba(10, 14, 26, 0.95)', 'high-color': 'rgba(36, 92, 223, 0.4)', 'horizon-blend': 0.04, 'space-color': 'rgba(10, 10, 25, 1)', 'star-intensity': 0.6 }, purple: { color: 'rgba(25, 15, 35, 0.9)', 'high-color': 'rgba(80, 50, 120, 0.5)', 'horizon-blend': 0.06, 'space-color': 'rgba(8, 5, 15, 1)', 'star-intensity': 0.8 } }; const atmosphere = customConfig || atmospherePresets[theme] || atmospherePresets.default; this.map.setFog(atmosphere); } /** * Setup interactive hover effects * @param {string} layerId - Layer to add hover to * @param {Function} callback - Optional callback on hover */ setupHoverEffects(layerId, callback = null) { let hoveredFeatureId = null; // Mouse enter this.map.on('mouseenter', layerId, (e) => { this.map.getCanvas().style.cursor = 'pointer'; if (e.features.length > 0) { if (hoveredFeatureId !== null) { this.map.setFeatureState( { source: layerId, id: hoveredFeatureId }, { hover: false } ); } hoveredFeatureId = e.features[0].id; this.map.setFeatureState( { source: layerId, id: hoveredFeatureId }, { hover: true } ); if (callback) callback(e.features[0]); } }); // Mouse leave this.map.on('mouseleave', layerId, () => { this.map.getCanvas().style.cursor = ''; if (hoveredFeatureId !== null) { this.map.setFeatureState( { source: layerId, id: hoveredFeatureId }, { hover: false } ); } hoveredFeatureId = null; }); } /** * Create a popup with formatted content * @param {Object} feature - GeoJSON feature * @param {Object} config - Popup configuration * @returns {string} HTML content for popup */ createPopupContent(feature, config = {}) { const props = feature.properties; const { title = props.name, metrics = [], showIncome = true, showRegion = true } = config; let html = `
`; // Add region and income if (showRegion || showIncome) { html += ''; } // Add metrics if (metrics.length > 0) { html += ''; } html += '
'; return html; } /** * Format income level for display */ formatIncome(income) { const formatted = { 'low': 'Low Income', 'lower-middle': 'Lower-Middle Income', 'upper-middle': 'Upper-Middle Income', 'high': 'High Income' }; return formatted[income] || income; } /** * Add a simple legend to the map * @param {Object} config - Legend configuration */ addLegend(config) { const { position = 'bottom-right', title = 'Legend', colorScale = 'coverage', labels = null } = config; const scale = COLOR_SCALES[colorScale] || COLOR_SCALES.coverage; const legendDiv = document.createElement('div'); legendDiv.className = `legend legend-${position}`; legendDiv.style.cssText = ` position: absolute; background: rgba(10, 14, 26, 0.95); padding: 20px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); z-index: 1000; min-width: 200px; `; // Position const positions = { 'top-left': 'top: 20px; left: 20px;', 'top-right': 'top: 20px; right: 20px;', 'bottom-left': 'bottom: 20px; left: 20px;', 'bottom-right': 'bottom: 20px; right: 20px;' }; legendDiv.style.cssText += positions[position] || positions['bottom-right']; // Build legend HTML let html = `

${title}

`; // Gradient bar const gradient = `linear-gradient(to right, ${scale.colors.join(', ')})`; html += `
`; // Labels const labelArray = labels || [ scale.stops[0], scale.stops[Math.floor(scale.stops.length / 2)], scale.stops[scale.stops.length - 1] ]; html += `
${labelArray.map(l => `${l}${scale.domain[0] === 0 && scale.domain[1] === 100 ? '%' : ''}`).join('')}
`; legendDiv.innerHTML = html; this.map.getContainer().parentElement.appendChild(legendDiv); return legendDiv; } } /** * Convenience function to create a complete vaccine visualization layer */ export function createVaccineLayer(map, config) { const factory = new LayerFactory(map); const { sourceId, layerId, vaccineType = 'coverage', sizeProperty = 'population', colorProperty = 'coverage', colorScale = 'coverage', atmosphere = 'default', legend = true } = config; // Apply atmosphere factory.applyGlobeAtmosphere({ theme: atmosphere }); // Create main layer const layer = factory.createCircleLayer({ id: layerId, source: sourceId, sizeProperty: sizeProperty, colorProperty: colorProperty, colorScale: colorScale }); map.addLayer(layer); // Setup hover effects factory.setupHoverEffects(layerId); // Add legend if requested if (legend) { factory.addLegend({ title: `${vaccineType.charAt(0).toUpperCase() + vaccineType.slice(1)} Coverage`, colorScale: colorScale }); } return factory; }