infinite-agents-public/mapbox_test/shared/layer-factory.js

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;
}