583 lines
17 KiB
JavaScript
583 lines
17 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<div class="vaccine-popup">
|
|
<h3 class="popup-title">${title}</h3>
|
|
`;
|
|
|
|
// Add region and income
|
|
if (showRegion || showIncome) {
|
|
html += '<div class="popup-meta">';
|
|
if (showRegion && props.region) {
|
|
html += `<span class="popup-region">${props.region}</span>`;
|
|
}
|
|
if (showIncome && props.income_level) {
|
|
html += `<span class="popup-income">${this.formatIncome(props.income_level)}</span>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
// Add metrics
|
|
if (metrics.length > 0) {
|
|
html += '<div class="popup-metrics">';
|
|
metrics.forEach(metric => {
|
|
const value = props[metric.property];
|
|
const formatted = metric.format ? metric.format(value) : value;
|
|
const className = metric.className || '';
|
|
|
|
html += `
|
|
<div class="popup-metric ${className}">
|
|
<span class="metric-label">${metric.label}</span>
|
|
<span class="metric-value">${formatted}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
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 = `<h3 style="margin: 0 0 15px 0; font-size: 14px; color: #9ca3af;">${title}</h3>`;
|
|
|
|
// Gradient bar
|
|
const gradient = `linear-gradient(to right, ${scale.colors.join(', ')})`;
|
|
html += `
|
|
<div style="
|
|
height: 20px;
|
|
border-radius: 4px;
|
|
background: ${gradient};
|
|
margin-bottom: 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
"></div>
|
|
`;
|
|
|
|
// Labels
|
|
const labelArray = labels || [
|
|
scale.stops[0],
|
|
scale.stops[Math.floor(scale.stops.length / 2)],
|
|
scale.stops[scale.stops.length - 1]
|
|
];
|
|
|
|
html += `
|
|
<div style="
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
color: #78909c;
|
|
">
|
|
${labelArray.map(l => `<span>${l}${scale.domain[0] === 0 && scale.domain[1] === 100 ? '%' : ''}</span>`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
}
|