diff --git a/cspell.config.json b/cspell.config.json index ebeb7df..772f1a3 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -7,6 +7,7 @@ "antipattern", "appcompat", "autoresizing", + "basemap", "bbox", "bindgen", "buildconfig", @@ -15,6 +16,7 @@ "cartography", "choropleth", "chromedevtools", + "classybrew", "classyrew", "CMYK", "Coblis", diff --git a/skills/README.md b/skills/README.md index a34e605..8d16f94 100644 --- a/skills/README.md +++ b/skills/README.md @@ -4,22 +4,23 @@ This directory contains [Agent Skills](https://agentskills.io) that provide doma ## Available Skills -| Skill | Description | -| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| [mapbox-geospatial-operations](./mapbox-geospatial-operations/) | Choosing between offline geometric tools and routing APIs for geospatial operations | -| [mapbox-google-maps-migration](./mapbox-google-maps-migration/) | Migration guide from Google Maps Platform to Mapbox GL JS with API equivalents and patterns | -| [mapbox-maplibre-migration](./mapbox-maplibre-migration/) | Migration guide between Mapbox GL JS and MapLibre GL JS in both directions | -| [mapbox-search-integration](./mapbox-search-integration/) | Complete workflow for implementing Mapbox search with discovery questions and best practices | -| [mapbox-search-patterns](./mapbox-search-patterns/) | Choosing the right search tool and parameters for geocoding and POI search | -| [mapbox-web-performance-patterns](./mapbox-web-performance-patterns/) | Performance optimization for Mapbox GL JS (initialization, markers, data loading, memory) | -| [mapbox-cartography](./mapbox-cartography/) | Map design principles, color theory, visual hierarchy, typography | -| [mapbox-web-integration-patterns](./mapbox-web-integration-patterns/) | Framework integration (React, Vue, Svelte, Angular, Next.js) | -| [mapbox-ios-patterns](./mapbox-ios-patterns/) | iOS integration with Swift, SwiftUI, UIKit | -| [mapbox-android-patterns](./mapbox-android-patterns/) | Android integration with Kotlin, Jetpack Compose | -| [mapbox-style-patterns](./mapbox-style-patterns/) | Common style patterns and layer configurations | -| [mapbox-style-quality](./mapbox-style-quality/) | Style validation, accessibility, optimization | -| [mapbox-token-security](./mapbox-token-security/) | Security best practices for access tokens | -| [mapbox-store-locator-patterns](./mapbox-store-locator-patterns/) | Store locator and location finder patterns with markers, filtering, and distance calculation | +| Skill | Description | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [mapbox-geospatial-operations](./mapbox-geospatial-operations/) | Choosing between offline geometric tools and routing APIs for geospatial operations | +| [mapbox-google-maps-migration](./mapbox-google-maps-migration/) | Migration guide from Google Maps Platform to Mapbox GL JS with API equivalents and patterns | +| [mapbox-maplibre-migration](./mapbox-maplibre-migration/) | Migration guide between Mapbox GL JS and MapLibre GL JS in both directions | +| [mapbox-search-integration](./mapbox-search-integration/) | Complete workflow for implementing Mapbox search with discovery questions and best practices | +| [mapbox-search-patterns](./mapbox-search-patterns/) | Choosing the right search tool and parameters for geocoding and POI search | +| [mapbox-web-performance-patterns](./mapbox-web-performance-patterns/) | Performance optimization for Mapbox GL JS (initialization, markers, data loading, memory) | +| [mapbox-cartography](./mapbox-cartography/) | Map design principles, color theory, visual hierarchy, typography | +| [mapbox-data-visualization-patterns](./mapbox-data-visualization-patterns/) | Data visualization patterns including choropleth, heat maps, clustering, 3D, and animated data | +| [mapbox-web-integration-patterns](./mapbox-web-integration-patterns/) | Framework integration (React, Vue, Svelte, Angular, Next.js) | +| [mapbox-ios-patterns](./mapbox-ios-patterns/) | iOS integration with Swift, SwiftUI, UIKit | +| [mapbox-android-patterns](./mapbox-android-patterns/) | Android integration with Kotlin, Jetpack Compose | +| [mapbox-style-patterns](./mapbox-style-patterns/) | Common style patterns and layer configurations | +| [mapbox-style-quality](./mapbox-style-quality/) | Style validation, accessibility, optimization | +| [mapbox-token-security](./mapbox-token-security/) | Security best practices for access tokens | +| [mapbox-store-locator-patterns](./mapbox-store-locator-patterns/) | Store locator and location finder patterns with markers, filtering, and distance calculation | ## Documentation diff --git a/skills/mapbox-data-visualization-patterns/AGENTS.md b/skills/mapbox-data-visualization-patterns/AGENTS.md new file mode 100644 index 0000000..8c2d733 --- /dev/null +++ b/skills/mapbox-data-visualization-patterns/AGENTS.md @@ -0,0 +1,533 @@ +# Data Visualization Patterns + +Quick reference for visualizing data on Mapbox maps. + +## Visualization Type Decision Matrix + +| Data Type | Visualization | Layer Type | Use For | +| --------------------- | ------------- | ---------------- | ----------------------------------- | +| **Regional/Polygons** | Choropleth | `fill` | Statistics, demographics, elections | +| **Point Density** | Heat Map | `heatmap` | Crime, events, incident clustering | +| **Point Density** | Clustering | `circle` | Grouped markers, aggregated counts | +| **Point Magnitude** | Bubble/Circle | `circle` | Earthquakes, sales, metrics | +| **3D Data** | Extrusions | `fill-extrusion` | Buildings, elevation, volume | +| **Flow/Network** | Lines | `line` | Traffic, routes, connections | + +## Data Structure + +All code snippets below use **Style expressions** to style features based on their property data. Expressions like `['get', 'value']` access properties from your GeoJSON features: + +```javascript +// Example GeoJSON feature +{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0323, 38.9131] // [longitude, latitude] + }, + "properties": { + "magnitude": 7.8, // Custom data property + "value": 42, // Another property + "category": "coffee" // Can be any data type + } +} +``` + +**Accessing properties:** + +```javascript +['get', 'magnitude']; // Returns 7.8 +['get', 'value']; // Returns 42 +['get', 'category']; // Returns "coffee" +``` + +## Choropleth Maps + +**Pattern:** Color-code regions by data values + +```javascript +map.addLayer({ + id: 'choropleth', + type: 'fill', + source: 'regions', + paint: { + 'fill-color': [ + 'interpolate', + ['linear'], + ['get', 'value'], + 0, + '#f0f9ff', // Low + 50, + '#7fcdff', + 100, + '#0080ff' // High + ], + 'fill-opacity': 0.75 + } +}); +``` + +**Color Scale Types:** + +```javascript +// Linear (continuous) +['interpolate', ['linear'], ['get', 'value'], 0, '#fff', 100, '#000'][ + // Steps (discrete buckets) + ('step', ['get', 'value'], '#fff', 25, '#ccc', 50, '#888', 75, '#000') +][ + // Categories (qualitative) + ('match', ['get', 'category'], 'A', '#ff0000', 'B', '#0000ff', '#cccccc') +]; +``` + +## Heat Maps + +**Pattern:** Show point density + +```javascript +map.addLayer({ + id: 'heatmap', + type: 'heatmap', + source: 'points', + paint: { + 'heatmap-weight': ['get', 'intensity'], + 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3], + 'heatmap-color': [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(33,102,172,0)', + 0.2, + 'rgb(103,169,207)', + 0.4, + 'rgb(209,229,240)', + 0.6, + 'rgb(253,219,199)', + 0.8, + 'rgb(239,138,98)', + 1, + 'rgb(178,24,43)' + ], + 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20] + } +}); + +// Show individual points at high zoom +map.addLayer({ + id: 'points', + type: 'circle', + source: 'points', + minzoom: 14, + paint: { + 'circle-radius': 6, + 'circle-color': '#ff4444' + } +}); +``` + +## Clustering (Point Density) + +**Pattern:** Group nearby points with aggregated counts + +```javascript +// Add source with clustering enabled +map.addSource('points', { + type: 'geojson', + data: data, + cluster: true, + clusterMaxZoom: 14, // Max zoom to cluster points on + clusterRadius: 50 // Radius of each cluster when clustering points (default 50) +}); + +// Clusters - sized by point count +map.addLayer({ + id: 'clusters', + type: 'circle', + source: 'points', + filter: ['has', 'point_count'], + paint: { + 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'], + 'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40] + } +}); + +// Cluster count labels +map.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'points', + filter: ['has', 'point_count'], + layout: { + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + 'text-size': 12 + } +}); + +// Unclustered points +map.addLayer({ + id: 'unclustered-point', + type: 'circle', + source: 'points', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-color': '#11b4da', + 'circle-radius': 6, + 'circle-stroke-width': 1, + 'circle-stroke-color': '#fff' + } +}); + +// Click to expand clusters +map.on('click', 'clusters', (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] }); + const clusterId = features[0].properties.cluster_id; + map.getSource('points').getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return; + map.easeTo({ center: features[0].geometry.coordinates, zoom: zoom }); + }); +}); +``` + +**When to use clustering vs heatmaps:** + +- **Clustering:** Discrete grouping, exact counts, click to expand +- **Heatmaps:** Continuous density visualization, smoother appearance + +## Bubble Maps + +**Pattern:** Size circles by magnitude + +```javascript +map.addLayer({ + id: 'bubbles', + type: 'circle', + source: 'data', + paint: { + 'circle-radius': ['interpolate', ['exponential', 2], ['get', 'magnitude'], 0, 2, 5, 20, 10, 100], + 'circle-color': ['interpolate', ['linear'], ['get', 'magnitude'], 0, '#ffffcc', 50, '#78c679', 100, '#006837'], + 'circle-opacity': 0.7, + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 1 + } +}); +``` + +## 3D Extrusions + +**Pattern:** Extrude polygons by height + +> **Note:** This example works with **classic styles only** (`streets-v12`, `dark-v11`, `light-v11`, etc.). The **Mapbox Standard style** includes 3D buildings with much greater detail by default. + +```javascript +// Add 3D buildings from basemap +map.on('load', () => { + // Insert the layer beneath any symbol layer + const layers = map.getStyle().layers; + const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id; + + map.addLayer( + { + id: 'add-3d-buildings', + source: 'composite', + 'source-layer': 'building', + filter: ['==', 'extrude', 'true'], + type: 'fill-extrusion', + minzoom: 15, + paint: { + 'fill-extrusion-color': '#aaa', + 'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'height']], + 'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'min_height']], + 'fill-extrusion-opacity': 0.6 + } + }, + labelLayerId + ); + + // Enable 3D view + map.setPitch(45); + map.setBearing(-17.6); +}); +``` + +**Data-driven 3D (custom data):** + +```javascript +// For your own data source +map.addLayer({ + id: '3d-data', + type: 'fill-extrusion', + source: 'your-data', + paint: { + 'fill-extrusion-height': ['get', 'height'], + 'fill-extrusion-base': ['get', 'base_height'], + 'fill-extrusion-color': [ + 'interpolate', + ['linear'], + ['get', 'height'], + 0, + '#fafa6e', + 100, + '#e64a45', + 200, + '#a63e3e' + ], + 'fill-extrusion-opacity': 0.9 + } +}); +``` + +## Line Visualization + +**Pattern:** Style lines by data + +```javascript +map.addLayer({ + id: 'traffic', + type: 'line', + source: 'roads', + paint: { + 'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 10000, 15], + 'line-color': [ + 'interpolate', + ['linear'], + ['get', 'speed'], + 0, + '#d73027', // Stopped + 30, + '#fee08b', // Moderate + 60, + '#1a9850' // Free flow + ] + } +}); +``` + +## Animated Data + +**Time-Series:** + +```javascript +let currentTime = 0; + +function animate() { + currentTime++; + map.getSource('data').setData(getDataForTime(currentTime)); + requestAnimationFrame(animate); +} +``` + +**Real-Time Updates:** + +```javascript +setInterval(async () => { + const data = await fetch('/api/live-data').then((r) => r.json()); + map.getSource('live').setData(data); +}, 5000); +``` + +## Performance + +**Data Size Guidelines:** + +| Size | Format | Strategy | +| ------- | ------------ | --------------------- | +| < 1 MB | GeoJSON | Direct load | +| 1-10 MB | GeoJSON | Consider vector tiles | +| > 10 MB | Vector Tiles | Required | + +**Vector Tiles:** + +```javascript +map.addSource('large-data', { + type: 'vector', + tiles: ['https://example.com/{z}/{x}/{y}.mvt'] +}); + +map.addLayer({ + id: 'data', + type: 'fill', + source: 'large-data', + 'source-layer': 'layer-name' +}); +``` + +**Feature State (Dynamic Styling):** + +```javascript +// GeoJSON source with generateId +map.addSource('data', { + type: 'geojson', + data: data, + generateId: true // Required for feature state +}); + +// Update state (GeoJSON source) +map.setFeatureState({ source: 'data', id: featureId }, { hover: true }); + +// Vector tile source - requires sourceLayer +map.addSource('vector-data', { + type: 'vector', + tiles: ['https://example.com/{z}/{x}/{y}.mvt'] +}); + +// Update state (vector source) +map.setFeatureState({ source: 'vector-data', id: featureId, sourceLayer: 'my-source-layer' }, { hover: true }); + +// Use in paint property +'fill-color': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + '#ff0000', + '#0000ff' +] +``` + +**Client-Side Filtering:** + +```javascript +// Filter without reloading data +map.setFilter('layer-id', ['>=', ['get', 'value'], threshold]); +``` + +**Progressive Loading:** + +```javascript +map.on('moveend', () => { + const bounds = map.getBounds(); + const visible = allData.features.filter((f) => bounds.contains(f.geometry.coordinates)); + map.getSource('data').setData({ type: 'FeatureCollection', features: visible }); +}); +``` + +## Color Scales + +**Accessible Colors (ColorBrewer):** + +```javascript +// Sequential (single hue) +const sequential = ['#f0f9ff', '#bae4ff', '#7fcdff', '#0080ff', '#001f5c']; + +// Diverging (two hues) +const diverging = ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850']; + +// Qualitative (distinct categories) +const qualitative = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00']; +``` + +## Legend Component + +```html +
Population: ${feature.properties.population.toLocaleString()}
+ ` + ) + .addTo(map); + } + }); + + map.on('mouseleave', 'states-layer', () => { + map.getCanvas().style.cursor = ''; + popup.remove(); + }); +}); +``` + +**Color Scale Strategies:** + +```javascript +// Linear interpolation (continuous scale) +'fill-color': [ + 'interpolate', + ['linear'], + ['get', 'value'], + 0, '#ffffcc', + 25, '#78c679', + 50, '#31a354', + 100, '#006837' +] + +// Step intervals (discrete buckets) +'fill-color': [ + 'step', + ['get', 'value'], + '#ffffcc', // Default color + 25, '#c7e9b4', + 50, '#7fcdbb', + 75, '#41b6c4', + 100, '#2c7fb8' +] + +// Case-based (categorical data) +'fill-color': [ + 'match', + ['get', 'category'], + 'residential', '#ffd700', + 'commercial', '#ff6b6b', + 'industrial', '#4ecdc4', + 'park', '#45b7d1', + '#cccccc' // Default +] +``` + +### Heat Maps + +**Best for:** Point density, event locations, incident clustering + +**Pattern:** Visualize density of points + +```javascript +map.on('load', () => { + // Add data source (points) + map.addSource('incidents', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.4194, 37.7749] + }, + properties: { + intensity: 1 + } + } + // ... more points + ] + } + }); + + // Add heatmap layer + map.addLayer({ + id: 'incidents-heat', + type: 'heatmap', + source: 'incidents', + maxzoom: 15, + paint: { + // Increase weight based on intensity property + 'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 6, 1], + // Increase intensity as zoom level increases + 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3], + // Color ramp for heatmap + 'heatmap-color': [ + 'interpolate', + ['linear'], + ['heatmap-density'], + 0, + 'rgba(33,102,172,0)', + 0.2, + 'rgb(103,169,207)', + 0.4, + 'rgb(209,229,240)', + 0.6, + 'rgb(253,219,199)', + 0.8, + 'rgb(239,138,98)', + 1, + 'rgb(178,24,43)' + ], + // Adjust radius by zoom level + 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20], + // Decrease opacity at higher zoom levels + 'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 15, 0] + } + }); + + // Add circle layer for individual points at high zoom + map.addLayer({ + id: 'incidents-point', + type: 'circle', + source: 'incidents', + minzoom: 14, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 4, 22, 30], + 'circle-color': '#ff4444', + 'circle-opacity': 0.8, + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 1 + } + }); +}); +``` + +### Clustering (Point Density) + +**Best for:** Grouping nearby points, aggregated counts, large point datasets + +**Pattern:** Client-side clustering for visualization + +Clustering is a valuable point density visualization technique alongside heat maps. Use clustering when you want **discrete grouping with exact counts** rather than a continuous density visualization. + +```javascript +map.on('load', () => { + // Add data source with clustering enabled + map.addSource('locations', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [ + // Your point features + ] + }, + cluster: true, + clusterMaxZoom: 14, // Max zoom to cluster points + clusterRadius: 50 // Radius of each cluster (default 50) + }); + + // Clustered circles - styled by point count + map.addLayer({ + id: 'clusters', + type: 'circle', + source: 'locations', + filter: ['has', 'point_count'], + paint: { + // Color clusters by count (step expression) + 'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'], + // Size clusters by count + 'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40] + } + }); + + // Cluster count labels + map.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'locations', + filter: ['has', 'point_count'], + layout: { + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + 'text-size': 12 + } + }); + + // Individual unclustered points + map.addLayer({ + id: 'unclustered-point', + type: 'circle', + source: 'locations', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-color': '#11b4da', + 'circle-radius': 6, + 'circle-stroke-width': 1, + 'circle-stroke-color': '#fff' + } + }); + + // Click handler to expand clusters + map.on('click', 'clusters', (e) => { + const features = map.queryRenderedFeatures(e.point, { + layers: ['clusters'] + }); + const clusterId = features[0].properties.cluster_id; + + // Get cluster expansion zoom + map.getSource('locations').getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return; + + map.easeTo({ + center: features[0].geometry.coordinates, + zoom: zoom + }); + }); + }); + + // Change cursor on hover + map.on('mouseenter', 'clusters', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'clusters', () => { + map.getCanvas().style.cursor = ''; + }); +}); +``` + +**Advanced: Custom Cluster Properties** + +```javascript +map.addSource('locations', { + type: 'geojson', + data: data, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: 50, + // Calculate custom cluster properties + clusterProperties: { + // Sum total values + sum: ['+', ['get', 'value']], + // Calculate max value + max: ['max', ['get', 'value']] + } +}); + +// Use custom properties in styling +'circle-color': [ + 'interpolate', + ['linear'], + ['get', 'sum'], + 0, + '#51bbd6', + 100, + '#f1f075', + 1000, + '#f28cb1' +]; +``` + +**When to use clustering vs heatmaps:** + +| Use Case | Clustering | Heatmap | +| -------------------------------- | -------------------------------- | -------------------------- | +| **Visual style** | Discrete circles with counts | Continuous gradient | +| **Interaction** | Click to expand/zoom | Visual density only | +| **Data granularity** | Exact counts visible | Approximate density | +| **Best for** | Store locators, event listings | Crime maps, incident areas | +| **Performance with many points** | Excellent (groups automatically) | Good | +| **User understanding** | Clear (numbered clusters) | Intuitive (heat analogy) | + +### 3D Extrusions + +**Best for:** Building heights, elevation data, volumetric representation + +**Pattern:** Extrude polygons based on data + +> **Note:** The example below works with **classic styles only** (`streets-v12`, `dark-v11`, `light-v11`, etc.). The **Mapbox Standard style** includes 3D buildings with much greater detail by default. + +```javascript +map.on('load', () => { + // Insert the layer beneath any symbol layer for proper ordering + const layers = map.getStyle().layers; + const labelLayerId = layers.find((layer) => layer.type === 'symbol' && layer.layout['text-field']).id; + + // Add 3D buildings from basemap + map.addLayer( + { + id: 'add-3d-buildings', + source: 'composite', + 'source-layer': 'building', + filter: ['==', 'extrude', 'true'], + type: 'fill-extrusion', + minzoom: 15, + paint: { + 'fill-extrusion-color': '#aaa', + // Smoothly transition height on zoom + 'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'height']], + 'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'min_height']], + 'fill-extrusion-opacity': 0.6 + } + }, + labelLayerId + ); + + // Enable pitch and bearing for 3D view + map.setPitch(45); + map.setBearing(-17.6); +}); +``` + +**Using Custom Data Source:** + +```javascript +map.on('load', () => { + // Add your own buildings data + map.addSource('custom-buildings', { + type: 'geojson', + data: 'https://example.com/buildings.geojson' + }); + + // Add 3D buildings layer + map.addLayer({ + id: '3d-custom-buildings', + type: 'fill-extrusion', + source: 'custom-buildings', + paint: { + // Height in meters + 'fill-extrusion-height': ['get', 'height'], + // Base height if building on terrain + 'fill-extrusion-base': ['get', 'base_height'], + // Color by building type or height + 'fill-extrusion-color': [ + 'interpolate', + ['linear'], + ['get', 'height'], + 0, + '#fafa6e', + 50, + '#eca25b', + 100, + '#e64a45', + 200, + '#a63e3e' + ], + 'fill-extrusion-opacity': 0.9 + } + }); +}); +``` + +**Data-Driven 3D Heights:** + +```javascript +// Population density visualization +'fill-extrusion-height': [ + 'interpolate', + ['linear'], + ['get', 'density'], + 0, 0, + 1000, 500, // 1000 people/sq mi = 500m height + 10000, 5000 +] + +// Revenue visualization (scale for visibility) +'fill-extrusion-height': [ + '*', + ['get', 'revenue'], + 0.001 // Scale factor +] +``` + +### Circle/Bubble Maps + +**Best for:** Point data with magnitude, proportional symbols + +**Pattern:** Size circles based on data values + +```javascript +map.on('load', () => { + map.addSource('earthquakes', { + type: 'geojson', + data: 'https://example.com/earthquakes.geojson' + }); + + // Size by magnitude, color by depth + map.addLayer({ + id: 'earthquakes', + type: 'circle', + source: 'earthquakes', + paint: { + // Size circles by magnitude + 'circle-radius': ['interpolate', ['exponential', 2], ['get', 'mag'], 0, 2, 5, 20, 8, 100], + // Color by depth + 'circle-color': [ + 'interpolate', + ['linear'], + ['get', 'depth'], + 0, + '#ffffcc', + 50, + '#a1dab4', + 100, + '#41b6c4', + 200, + '#2c7fb8', + 300, + '#253494' + ], + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 1, + 'circle-opacity': 0.75 + } + }); + + // Add popup on click + map.on('click', 'earthquakes', (e) => { + const props = e.features[0].properties; + new mapboxgl.Popup() + .setLngLat(e.features[0].geometry.coordinates) + .setHTML( + ` +Depth: ${props.depth} km
+Time: ${new Date(props.time).toLocaleString()}
+ ` + ) + .addTo(map); + }); +}); +``` + +### Line Data Visualization + +**Best for:** Routes, flows, connections, networks + +**Pattern:** Style lines based on data + +```javascript +map.on('load', () => { + map.addSource('traffic', { + type: 'geojson', + data: 'https://example.com/traffic.geojson' + }); + + // Traffic flow with data-driven styling + map.addLayer({ + id: 'traffic-lines', + type: 'line', + source: 'traffic', + paint: { + // Width by traffic volume + 'line-width': ['interpolate', ['exponential', 2], ['get', 'volume'], 0, 1, 1000, 5, 10000, 15], + // Color by speed (congestion) + 'line-color': [ + 'interpolate', + ['linear'], + ['get', 'speed'], + 0, + '#d73027', // Red: stopped + 15, + '#fc8d59', // Orange: slow + 30, + '#fee08b', // Yellow: moderate + 45, + '#d9ef8b', // Light green: good + 60, + '#91cf60', // Green: free flow + 75, + '#1a9850' + ], + 'line-opacity': 0.8 + } + }); +}); +``` + +## Animated Data Visualizations + +### Time-Series Animation + +**Pattern:** Animate data over time + +```javascript +let currentTime = 0; +const times = [0, 6, 12, 18, 24]; // Hours of day +let animationId; + +map.on('load', () => { + map.addSource('hourly-data', { + type: 'geojson', + data: getDataForTime(currentTime) + }); + + map.addLayer({ + id: 'data-layer', + type: 'circle', + source: 'hourly-data', + paint: { + 'circle-radius': 8, + 'circle-color': ['get', 'color'] + } + }); + + // Animation loop + function animate() { + currentTime = (currentTime + 1) % times.length; + + // Update data + map.getSource('hourly-data').setData(getDataForTime(times[currentTime])); + + // Update UI + document.getElementById('time-display').textContent = `${times[currentTime]}:00`; + + animationId = setTimeout(animate, 1000); // Update every second + } + + // Start animation + document.getElementById('play-button').addEventListener('click', () => { + if (animationId) { + clearTimeout(animationId); + animationId = null; + } else { + animate(); + } + }); +}); + +function getDataForTime(hour) { + // Fetch or generate data for specific time + return { + type: 'FeatureCollection', + features: data.filter((d) => d.properties.hour === hour) + }; +} +``` + +### Real-Time Data Updates + +**Pattern:** Update data from live sources + +```javascript +map.on('load', () => { + map.addSource('live-data', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [] + } + }); + + map.addLayer({ + id: 'live-points', + type: 'circle', + source: 'live-data', + paint: { + 'circle-radius': 6, + 'circle-color': '#ff4444' + } + }); + + // Poll for updates every 5 seconds + setInterval(async () => { + const response = await fetch('https://api.example.com/live-data'); + const data = await response.json(); + + // Update source + map.getSource('live-data').setData(data); + }, 5000); + + // Or use WebSocket for real-time updates + const ws = new WebSocket('wss://api.example.com/live'); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + map.getSource('live-data').setData(data); + }; +}); +``` + +### Smooth Transitions + +**Pattern:** Animate property changes + +```javascript +// Smoothly transition circle sizes +function updateVisualization(newData) { + map.getSource('data-source').setData(newData); + + // Animate circle radius + const currentRadius = map.getPaintProperty('data-layer', 'circle-radius'); + const targetRadius = ['get', 'newSize']; + + // Use setPaintProperty with transition + map.setPaintProperty('data-layer', 'circle-radius', targetRadius); + + // Or use expressions for smooth interpolation + map.setPaintProperty('data-layer', 'circle-radius', ['interpolate', ['linear'], ['get', 'value'], 0, 2, 100, 20]); +} +``` + +## Performance Optimization + +### Vector Tiles vs GeoJSON + +**When to use each:** + +| Data Size | Format | Reason | +| --------- | ----------------------- | --------------------------------------- | +| < 1 MB | GeoJSON | Simple, no processing needed | +| 1-10 MB | GeoJSON or Vector Tiles | Consider data update frequency | +| > 10 MB | Vector Tiles | Better performance, progressive loading | + +**Vector Tile Pattern:** + +```javascript +map.addSource('large-dataset', { + type: 'vector', + tiles: ['https://example.com/tiles/{z}/{x}/{y}.mvt'], + minzoom: 0, + maxzoom: 14 +}); + +map.addLayer({ + id: 'data-layer', + type: 'fill', + source: 'large-dataset', + 'source-layer': 'data-layer-name', // Layer name in the tileset + paint: { + 'fill-color': ['get', 'color'], + 'fill-opacity': 0.7 + } +}); +``` + +### Feature State for Dynamic Styling + +**Pattern:** Update styling without modifying geometry + +```javascript +map.on('load', () => { + map.addSource('states', { + type: 'geojson', + data: statesData, + generateId: true // Important for feature state + }); + + map.addLayer({ + id: 'states', + type: 'fill', + source: 'states', + paint: { + 'fill-color': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + '#ff0000', // Hover color + '#3b9ddd' // Default color + ] + } + }); + + let hoveredStateId = null; + + // Update feature state on hover + map.on('mousemove', 'states', (e) => { + if (e.features.length > 0) { + if (hoveredStateId !== null) { + map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false }); + } + + hoveredStateId = e.features[0].id; + + map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: true }); + } + }); + + map.on('mouseleave', 'states', () => { + if (hoveredStateId !== null) { + map.setFeatureState({ source: 'states', id: hoveredStateId }, { hover: false }); + } + hoveredStateId = null; + }); +}); +``` + +### Filtering Large Datasets + +**Pattern:** Filter data client-side for performance + +```javascript +map.on('load', () => { + map.addSource('all-data', { + type: 'geojson', + data: largeDataset + }); + + map.addLayer({ + id: 'filtered-data', + type: 'circle', + source: 'all-data', + filter: ['>=', ['get', 'value'], 50], // Only show values >= 50 + paint: { + 'circle-radius': 6, + 'circle-color': '#ff4444' + } + }); + + // Update filter dynamically + function updateFilter(minValue) { + map.setFilter('filtered-data', ['>=', ['get', 'value'], minValue]); + } + + // Slider for dynamic filtering + document.getElementById('filter-slider').addEventListener('input', (e) => { + updateFilter(parseFloat(e.target.value)); + }); +}); +``` + +### Progressive Loading + +**Pattern:** Load data in chunks as needed + +```javascript +// Helper to check if feature is in bounds +function isFeatureInBounds(feature, bounds) { + const coords = feature.geometry.coordinates; + + // Handle different geometry types + if (feature.geometry.type === 'Point') { + return bounds.contains(coords); + } else if (feature.geometry.type === 'LineString') { + return coords.some((coord) => bounds.contains(coord)); + } else if (feature.geometry.type === 'Polygon') { + return coords[0].some((coord) => bounds.contains(coord)); + } + return false; +} + +const bounds = map.getBounds(); +const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds)); + +map.getSource('data-source').setData({ + type: 'FeatureCollection', + features: visibleData +}); + +// Reload on map move with debouncing +let updateTimeout; +map.on('moveend', () => { + clearTimeout(updateTimeout); + updateTimeout = setTimeout(() => { + const bounds = map.getBounds(); + const visibleData = allData.features.filter((feature) => isFeatureInBounds(feature, bounds)); + + map.getSource('data-source').setData({ + type: 'FeatureCollection', + features: visibleData + }); + }, 150); +}); +``` + +## Legends and UI Controls + +### Color Scale Legend + +```html +