@amadmike/maplibre-scalar-flow
v2.0.1
Published
MapLibre visualization plugin for scalar fields and vector-field particles.
Maintainers
Readme
MapLibre Scalar Flow
A high-performance WebGL plugin for MapLibre GL JS that provides advanced visualization of scalar fields and vector flow data. Perfect for displaying weather data, oceanographic currents, wind patterns, temperature fields, and other scientific datasets.
Features
🎨 Advanced Scalar Visualization
- Multiple interpolation modes (Auto, Nearest, Bilinear, Bicubic, Lanczos, Smoothstep, RBF)
- Customizable color schemes with threshold-based mapping
- Multiple value scaling modes (Linear, Gamma, Log)
- Real-time blur and contrast adjustments
⚡ Dynamic Particle Systems
- GPU-accelerated particle animation
- Adaptive particle density based on zoom level
- Customizable trail effects and opacity
- Speed-based color coding
- Configurable drop rates and movement physics
🔧 Developer-Friendly
- TypeScript support with full type definitions
- Unified API for both scalar and particle layers
- Automatic layer type inference
- Metadata extraction from image EXIF data
- Comprehensive error handling
Installation
npm install @amadmike/maplibre-scalar-flowQuick Start
import maplibregl from 'maplibre-gl';
import { ScalarFlow, InterpolationMode } from '@amadmike/maplibre-scalar-flow';
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [-100, 40],
zoom: 3,
});
const scalarFlow = new ScalarFlow(map);
// Display temperature data
await scalarFlow.setLayer('path/to/temperature.png', {
id: 'temperature',
colors: [
{ threshold: -20, value: '#000080' }, // Deep blue (cold)
{ threshold: 0, value: '#0080ff' }, // Blue (freezing)
{ threshold: 10, value: '#00ff80' }, // Green (cool)
{ threshold: 20, value: '#ffff00' }, // Yellow (warm)
{ threshold: 30, value: '#ff8000' }, // Orange (hot)
{ threshold: 40, value: '#ff0000' }, // Red (very hot)
],
dataRange: [-30, 50],
interpolation: InterpolationMode.Bicubic,
});API Reference
ScalarFlow Class
Constructor
new ScalarFlow(map: Map, beforeLayerId?: string)Methods
setLayer(source, config, kind?)
Sets up a visualization layer with the specified configuration.
await scalarFlow.setLayer(
'path/to/data.png',
{
// ... configuration
},
'scalar' // optional, auto-detected if omitted
);updateLayer(options, kind?)
Updates an existing layer's configuration.
scalarFlow.updateLayer({
contrast: 1.5,
interpolation: InterpolationMode.Lanczos,
}, 'scalar');removeLayer(kind?)
Removes the specified layer(s).
scalarFlow.removeLayer('scalar'); // Remove scalar layer
scalarFlow.removeLayer('particles'); // Remove particle layer
scalarFlow.removeLayer('all'); // Remove all layers (default)Layer Instance Methods
Both scalar and particle layers provide additional methods for fine-grained control:
setImage(source)
Sets the image source for the layer.
// Set from URL
layer.setImage('path/to/data.png');
// Set from image element
const img = new Image();
img.src = 'data.png';
img.onload = () => layer.setImage(img);
// Clear the layer
layer.setImage(null);onceImageApplied(callback)
Registers a callback to execute after an image is loaded and processed.
layer.onceImageApplied(() => {
console.log('Image loaded and processed');
// Now safe to update configuration
layer.updateConfig({ contrast: 1.5 });
});
layer.setImage('data.png');clearRangeFlags()
Clears manually set data ranges to allow metadata from new images to take precedence.
// Clear any previously set ranges
layer.clearRangeFlags();
// Now metadata from new images will be applied
layer.setImage('new-data-with-metadata.png');resetAutoRange()
Resets the layer to automatic range detection mode.
// Reset to automatic range detection
layer.resetAutoRange();setZoomDebounceInterval(interval)
Configures the debounce interval for zoom interpolation. During zooming, bilinear interpolation will be used every N frames, with nearest neighbor used for all other frames.
// Use bilinear every 5th frame during zoom (more performance, less quality)
layer.setZoomDebounceInterval(5);
// Use bilinear every frame during zoom (smooth but slower)
layer.setZoomDebounceInterval(1);
// Use bilinear every 3rd frame (default)
layer.setZoomDebounceInterval(3);Configuration
Scalar Layer Configuration
interface ScalarLayerConfig {
colors: ColorConfiguration;
dataRange?: [number, number] | [[number, number], [number, number]];
interpolation?: InterpolationMode;
blurSigma?: number;
contrast?: number;
valueScale?: ValueScaleMode;
gamma?: number;
logK?: number;
}Particle Layer Configuration
interface ParticleLayerConfig {
dataRange?: [[number, number], [number, number]];
numParticles?: number;
fadeOpacity?: number;
speedFactor?: number;
dropRate?: number;
dropRateBump?: number;
colored?: boolean;
trailAlpha?: number;
}Color Configuration
Colors can be specified in multiple formats and support up to 20 thresholds for high-quality visualizations:
// Object format
colors: [
{ threshold: 0, value: '#000080' },
{ threshold: 10, value: '#0080ff' },
{ threshold: 20, value: '#00ff80' },
]
// Tuple format
colors: [
[0, '#000080'],
[10, '#0080ff'],
[20, '#00ff80'],
]
// Mixed format
colors: [
{ threshold: 0, value: 'blue' },
[10, 'rgb(0, 128, 255)'],
{ threshold: 20, value: 'hsl(120, 100%, 50%)' },
]
// Extended palette with 20 thresholds for high-quality visualization
colors: [
[-40, '#000033'], [-35, '#000066'], [-30, '#003399'], [-25, '#0066ff'],
[-20, '#00ccff'], [-15, '#00ffcc'], [-10, '#66ff66'], [-5, '#ccff00'],
[0, '#ffff00'], [5, '#ffcc00'], [10, '#ff9900'], [15, '#ff6600'],
[20, '#ff3300'], [25, '#ff0000'], [30, '#cc0000'], [35, '#990000'],
[40, '#660000'], [45, '#330000'], [50, '#000000']
]💡 Palette Optimization: If you provide fewer than 20 colors, the library automatically pads the palette with the last color or interpolates between colors to create a smooth 20-color gradient. This ensures optimal visual quality regardless of your input palette size.
Data Range Configuration
The dataRange property supports both scalar and vector data:
// Scalar data range
dataRange: [0, 100]
// Vector data range: [[uMin, uMax], [vMin, vMax]]
dataRange: [[-50, 50], [-30, 30]]💡 Metadata Auto-Detection: If you don't specify
dataRange, the library automatically extracts data ranges from image metadata (EXIF User Comment field). This eliminates the need to manually specify ranges for many datasets. See the Image Formats and Metadata section for details.
Image Formats and Metadata
Supported Image Formats
The library supports standard web image formats for both scalar and vector data:
- PNG (recommended) - Lossless compression, supports transparency
- JPEG - Lossy compression, smaller file sizes
- WebP - Modern format with good compression and quality
- GIF - Limited to 256 colors, not recommended for scientific data
Metadata Parsing
The library automatically extracts metadata from image files to improve visualization quality. This eliminates the need to manually specify data ranges in many cases.
EXIF User Comment Metadata
The library reads metadata from the EXIF User Comment field, which can contain JSON-formatted data range information:
// Example metadata in image EXIF User Comment
{
"min": -40,
"max": 50,
"uMin": -100,
"uMax": 100,
"vMin": -80,
"vMax": 80,
"units": "celsius",
"description": "Temperature data for North America"
}Supported Metadata Fields
| Field | Description | Example |
|-------|-------------|---------|
| min, max | Scalar data range | {"min": -40, "max": 50} |
| data_min, data_max | Alternative scalar range keys | {"data_min": -40, "data_max": 50} |
| uMin, uMax | U-component range for vector data | {"uMin": -100, "uMax": 100} |
| vMin, vMax | V-component range for vector data | {"vMin": -80, "vMax": 80} |
| u_min, u_max | Alternative U-component keys | {"u_min": -100, "u_max": 100} |
| v_min, v_max | Alternative V-component keys | {"v_min": -80, "v_max": 80} |
| units | Data units for reference | {"units": "celsius"} |
| description | Human-readable description | {"description": "Wind vectors"} |
Adding Metadata to Images
You can add metadata to your images using various tools:
Using ImageMagick:
# Add metadata to PNG
convert input.png -set comment '{"min": -40, "max": 50, "units": "celsius"}' output.png
# Add metadata to JPEG
exiftool -comment='{"min": -40, "max": 50, "units": "celsius"}' input.jpgUsing Python with Pillow:
from PIL import Image
import json
# Load image
img = Image.open('temperature.png')
# Add metadata
metadata = {
"min": -40,
"max": 50,
"units": "celsius",
"description": "Temperature data"
}
# Save with metadata
img.save('temperature_with_metadata.png', comment=json.dumps(metadata))Using Node.js with sharp:
const sharp = require('sharp');
await sharp('input.png')
.png({ comment: JSON.stringify({
min: -40,
max: 50,
units: 'celsius'
})})
.toFile('output.png');Metadata Priority
When metadata is available, the library follows this priority order:
- Manual configuration - If you explicitly set
dataRangein the config, it takes precedence - Image metadata - EXIF User Comment data is used if no manual range is provided
- Default values - Falls back to
[0, 1]for scalar data or[[0, 1], [0, 1]]for vector data
Clearing Metadata Flags
If you want to force the library to use metadata from a new image (overriding previously set ranges), use the clearRangeFlags() method:
// Clear any previously set ranges to allow metadata override
layer.clearRangeFlags();
// Now when setting a new image, metadata ranges will be applied
layer.setImage('new-data-with-metadata.png');Image Requirements
Scalar Data Images
- Single channel (grayscale) or multi-channel (RGBA/RGBAA)
- Linear data (not gamma-corrected) for best results
- Consistent bit depth (8-bit, 16-bit, or 32-bit)
- Proper orientation (top-left origin)
Vector Data Images
- Two-channel format where:
- Red channel = U-component (eastward velocity)
- Green channel = V-component (northward velocity)
- Blue channel can be used for additional data or left as zero
- Alpha channel for masking (optional)
Recommended Image Specifications
| Use Case | Format | Size | Bit Depth | Notes | |----------|--------|------|-----------|-------| | Weather data | PNG | 512x512 to 2048x2048 | 8-bit or 16-bit | Lossless, good compression | | Ocean currents | PNG | 1024x1024 to 4096x4096 | 16-bit | High precision needed | | Wind vectors | PNG | 256x256 to 1024x1024 | 8-bit | Good balance of quality/size | | Real-time feeds | JPEG | 512x512 to 1024x1024 | 8-bit | Faster loading, smaller size |
Performance Considerations
- Image size affects memory usage and rendering performance
- PNG files load slower but provide better quality for scientific data
- JPEG files load faster but may introduce artifacts in data visualization
- Metadata parsing adds minimal overhead and improves user experience
Comprehensive Examples
1. Temperature Visualization
await scalarFlow.setLayer('temperature.png', {
colors: [
{ threshold: -40, value: '#000033' },
{ threshold: -20, value: '#000066' },
{ threshold: -10, value: '#003399' },
{ threshold: 0, value: '#0066ff' },
{ threshold: 10, value: '#ffff00' },
{ threshold: 20, value: '#ff9900' },
{ threshold: 30, value: '#ff3300' },
{ threshold: 40, value: '#ff0000' },
],
dataRange: [-50, 50],
interpolation: InterpolationMode.Bicubic,
contrast: 1.2,
blurSigma: 0.5,
});2. Wind Flow Particles
await scalarFlow.setLayer('wind-vectors.png', {
dataRange: [[-100, 100], [-100, 100]], // u and v components in km/h
numParticles: 50000,
speedFactor: 0.8,
fadeOpacity: 0.98,
colored: true,
trailAlpha: 0.4,
dropRate: 0.008,
dropRateBump: 0.02,
});3. Ocean Current Visualization
// First, show the current speed as a scalar field
await scalarFlow.setLayer('ocean-current-speed.png', {
colors: [
[0, 'rgba(0, 0, 80, 0.6)'], // Slow - dark blue
[0.5, 'rgba(0, 100, 200, 0.7)'], // Medium - blue
[1.0, 'rgba(100, 200, 255, 0.8)'], // Fast - light blue
[2.0, 'rgba(255, 255, 0, 0.9)'], // Very fast - yellow
[3.0, 'rgba(255, 100, 0, 1.0)'], // Extreme - orange
],
dataRange: [0, 3.5],
interpolation: InterpolationMode.Smoothstep,
contrast: 1.3,
});
// Then add particle flow
await scalarFlow.setLayer('ocean-current-vectors.png', {
dataRange: [[-3, 3], [-3, 3]], // Current velocity in m/s
numParticles: 30000,
speedFactor: 1.2,
colored: true,
trailAlpha: 0.3,
fadeOpacity: 0.99,
}, 'particles');4. Precipitation with Custom Scaling
await scalarFlow.setLayer('precipitation.png', {
colors: [
[0, 'transparent'],
[0.1, '#e6f3ff'], // Very light rain
[1, '#99d6ff'], // Light rain
[5, '#4da6ff'], // Moderate rain
[10, '#0080ff'], // Heavy rain
[25, '#0066cc'], // Very heavy rain
[50, '#004499'], // Extreme rain
],
dataRange: [0, 60],
valueScale: ValueScaleMode.Log,
logK: 10,
interpolation: InterpolationMode.Lanczos,
});5. Multi-layered Weather Visualization
const weatherFlow = new ScalarFlow(map);
// Temperature base layer
await weatherFlow.setLayer('temperature.png', {
colors: [
[-30, '#1a0d4d'],
[-20, '#2d1b69'],
[-10, '#4a3c8c'],
[0, '#6666cc'],
[10, '#8cb3d9'],
[20, '#b3d9cc'],
[30, '#d9e6b3'],
[40, '#f2f2b3'],
[50, '#ffcc99'],
],
dataRange: [-35, 55],
});
// Wind vectors overlay
await weatherFlow.setLayer('wind.png', {
dataRange: [[-150, 150], [-150, 150]], // km/h
numParticles: 25000,
speedFactor: 0.6,
colored: false, // White particles for contrast
trailAlpha: 0.2,
fadeOpacity: 0.995,
}, 'particles');
// Update layers dynamically
setInterval(() => {
// Update with new weather data
weatherFlow.setLayer('temperature-updated.png', {
dataRange: [-35, 55],
});
}, 300000); // Every 5 minutes6. Interactive Controls
class WeatherVisualization {
private scalarFlow: ScalarFlow;
constructor(map: maplibregl.Map) {
this.scalarFlow = new ScalarFlow(map);
}
async loadTemperatureData(url: string) {
await this.scalarFlow.setLayer(url, {
colors: this.getTemperatureColors(),
dataRange: [-40, 50],
interpolation: InterpolationMode.Auto,
});
}
updateInterpolation(mode: InterpolationMode) {
this.scalarFlow.updateLayer({
interpolation: mode,
}, 'scalar');
}
updateContrast(value: number) {
this.scalarFlow.updateLayer({
contrast: value,
}, 'scalar');
}
toggleParticles(enabled: boolean) {
if (enabled) {
this.scalarFlow.setLayer('wind-vectors.png', {
dataRange: [[-100, 100], [-100, 100]],
numParticles: 40000,
colored: true,
}, 'particles');
} else {
this.scalarFlow.removeLayer('particles');
}
}
private getTemperatureColors() {
return [
{ threshold: -40, value: '#000033' },
{ threshold: -30, value: '#000066' },
{ threshold: -20, value: '#003399' },
{ threshold: -10, value: '#0066ff' },
{ threshold: 0, value: '#00ccff' },
{ threshold: 10, value: '#66ff66' },
{ threshold: 20, value: '#ccff00' },
{ threshold: 30, value: '#ffff00' },
{ threshold: 40, value: '#ff6600' },
{ threshold: 50, value: '#ff0000' },
];
}
}
// Usage
const weather = new WeatherVisualization(map);
await weather.loadTemperatureData('temperature.png');
// Add UI controls
document.getElementById('contrast-slider')?.addEventListener('input', (e) => {
weather.updateContrast(parseFloat((e.target as HTMLInputElement).value));
});
document.getElementById('particles-toggle')?.addEventListener('change', (e) => {
weather.toggleParticles((e.target as HTMLInputElement).checked);
});7. Metadata-Driven Visualization
This example shows how to leverage image metadata for automatic data range detection:
// Create a layer without specifying dataRange - it will use metadata
await scalarFlow.setLayer('temperature-with-metadata.png', {
colors: [
[-40, '#000033'], // Deep blue (very cold)
[-20, '#003399'], // Blue (cold)
[0, '#00ccff'], // Cyan (freezing)
[20, '#66ff66'], // Green (cool)
[40, '#ffff00'], // Yellow (warm)
[60, '#ff6600'], // Orange (hot)
],
// No dataRange specified - will be extracted from image metadata
interpolation: InterpolationMode.Bicubic,
});
// Later, if you want to force metadata from a new image
const layer = scalarFlow.scalarLayer; // Access the layer instance
if (layer) {
// Clear any manually set ranges
layer.clearRangeFlags();
// Now the new image's metadata will be applied
layer.setImage('updated-temperature-with-metadata.png');
}Creating Images with Metadata
Here's how to prepare images with embedded metadata:
Python script for batch processing:
from PIL import Image
import json
import os
def add_metadata_to_image(input_path, output_path, metadata):
"""Add metadata to an image file."""
with Image.open(input_path) as img:
# Convert metadata to JSON string
comment = json.dumps(metadata, separators=(',', ':'))
# Save with metadata
if output_path.lower().endswith('.png'):
img.save(output_path, comment=comment)
elif output_path.lower().endswith('.jpg'):
img.save(output_path, comment=comment, quality=95)
else:
raise ValueError("Unsupported format")
# Example: Process temperature data
temperature_files = [
('temp_jan.png', {'min': -40, 'max': 50, 'units': 'celsius', 'month': 'january'}),
('temp_feb.png', {'min': -35, 'max': 55, 'units': 'celsius', 'month': 'february'}),
('temp_mar.png', {'min': -20, 'max': 60, 'units': 'celsius', 'month': 'march'}),
]
for filename, metadata in temperature_files:
input_file = f'raw/{filename}'
output_file = f'processed/{filename}'
if os.path.exists(input_file):
add_metadata_to_image(input_file, output_file, metadata)
print(f'Processed {filename} with metadata')Node.js script for real-time data:
const sharp = require('sharp');
const fs = require('fs');
async function createImageWithMetadata(data, metadata, outputPath) {
// Create image from data array
const imageBuffer = await sharp(data, {
raw: {
width: data[0].length,
height: data.length,
channels: 1
}
})
.png({
comment: JSON.stringify(metadata),
compressionLevel: 9
})
.toBuffer();
// Save to file
fs.writeFileSync(outputPath, imageBuffer);
console.log(`Created ${outputPath} with metadata`);
}
// Example: Create wind vector image with metadata
const windData = generateWindVectors(); // Your data generation function
const metadata = {
uMin: -100,
uMax: 100,
vMin: -80,
vMax: 80,
units: 'km/h',
timestamp: new Date().toISOString(),
description: 'Wind vectors for North America'
};
await createImageWithMetadata(windData, metadata, 'wind-vectors.png');8. High-Resolution Color Mapping with 20 Thresholds
This example demonstrates the library's capability to handle up to 20 color thresholds for extremely detailed visualizations:
// Create a highly detailed temperature visualization with 20 thresholds
await scalarFlow.setLayer('high-res-temperature.png', {
colors: [
// Deep cold blues
[-40, '#000033'], [-35, '#000066'], [-30, '#003399'], [-25, '#0066ff'],
// Cold to freezing
[-20, '#00ccff'], [-15, '#00ffcc'], [-10, '#66ff66'], [-5, '#ccff00'],
// Freezing to cool
[0, '#ffff00'], [5, '#ffcc00'], [10, '#ff9900'], [15, '#ff6600'],
// Warm to hot
[20, '#ff3300'], [25, '#ff0000'], [30, '#cc0000'], [35, '#990000'],
// Very hot to extreme
[40, '#660000'], [45, '#330000'], [50, '#000000']
],
// No dataRange specified - will use image metadata
interpolation: InterpolationMode.Lanczos,
contrast: 1.1,
blurSigma: 0.3,
});
// Update with real-time data using the same detailed palette
setInterval(async () => {
await scalarFlow.setLayer('updated-high-res-temp.png', {
// Keep the same colors for consistency
colors: [
[-40, '#000033'], [-35, '#000066'], [-30, '#003399'], [-25, '#0066ff'],
[-20, '#00ccff'], [-15, '#00ffcc'], [-10, '#66ff66'], [-5, '#ccff00'],
[0, '#ffff00'], [5, '#ffcc00'], [10, '#ff9900'], [15, '#ff6600'],
[20, '#ff3300'], [25, '#ff0000'], [30, '#cc0000'], [35, '#990000'],
[40, '#660000'], [45, '#330000'], [50, '#000000']
]
});
}, 60000); // Update every minuteEnums and Constants
InterpolationMode
enum InterpolationMode {
Auto = 0, // Zoom-dependent interpolation (default)
Nearest = 1, // Nearest neighbor (pixelated)
Bilinear = 2, // Bilinear interpolation
Bicubic = 3, // Bicubic interpolation
Lanczos = 4, // Lanczos resampling
Smoothstep = 5, // Smoothstep interpolation
Rbf = 6, // Radial basis function
}ValueScaleMode
enum ValueScaleMode {
Linear = 0, // Linear scaling (default)
Gamma = 1, // Gamma correction
Log = 2, // Logarithmic scaling
}Performance Tips
- Particle Count: Start with 25,000-50,000 particles and adjust based on performance
- Interpolation: Use
Automode for best quality/performance balance - Interaction Performance: The library automatically optimizes during map interactions:
- Scalar layers: Use advanced temporary texture system during zoom (creates high-quality textures periodically, uses nearest neighbor from current zoom level)
- Particle layers: Completely hidden during zoom and move for maximum performance
- Updates: Use
updateLayer()instead of recreating layers for better performance - Temporary Texture System: During zoom operations, the library creates temporary high-quality textures:
- Periodic Creation: New textures are generated every few frames or when zoom level changes significantly
- Quality Preservation: Uses original interpolation settings for temporary textures
- Memory Efficient: Temporary textures are automatically cleaned up when zoom ends
- Smooth Experience: Nearest neighbor sampling from current zoom level provides smooth interaction
Browser Support
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
Requires WebGL support.
License
MIT License - see LICENSE file for details.
Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our GitHub repository.
Support
For questions, issues, or feature requests, please visit our GitHub Issues page.
