Skill: mapbox/mapbox-data-visualization-patterns (version 1.0.0)

--- name: mapbox-data-visualization-patterns description: Patterns for visualizing data on maps including choropleth maps, heat maps, 3D visualizations, data-driven styling, and animated data. Covers layer types, color scales, and performance optimization. --- # Data Visualization Patterns Skill Comprehensive patterns for visualizing data on Mapbox maps. Covers choropleth maps, heat maps, 3D extrusions, data-driven styling, animated visualizations, and performance optimization for data-heavy applications. ## When to Use This Skill Use this skill when: - Visualizing statistical data on maps (population, sales, demographics) - Creating choropleth maps with color-coded regions - Building heat maps or clustering for density visualization - Adding 3D visualizations (building heights, terrain elevation) - Implementing data-driven styling based on properties - Animating time-series data - Working with large datasets that require optimization ## Visualization Types ### Choropleth Maps **Best for:** Regional data (states, counties, zip codes), statistical comparisons **Pattern:** Color-code polygons based on data values ```javascript map.on('load', () => { // Add data source (GeoJSON with properties) map.addSource('states', { type: 'geojson', data: 'https://example.com/states.geojson' // Features with population property }); // Add fill layer with data-driven color map.addLayer({ id: 'states-layer', type: 'fill', source: 'states', paint: { 'fill-color': [ 'interpolate', ['linear'], ['get', 'population'], 0, '#f0f9ff', // Light blue for low population 500000, '#7fcdff', 1000000, '#0080ff', 5000000, '#0040bf', // Dark blue for high population 10000000, '#001f5c' ], 'fill-opacity': 0.75 } }); // Add border layer map.addLayer({ id: 'states-border', type: 'line', source: 'states', paint: { 'line-color': '#ffffff', 'line-width': 1 } }); // Add hover effect with reusable popup const popup = new mapboxgl.Popup({ closeButton: false, closeOnClick: false }); map.on('mousemove', 'states-layer', (e) => { if (e.features.length > 0) { map.getCanvas().style.cursor = 'pointer'; const feature = e.features[0]; popup .setLngLat(e.lngLat) .setHTML( ` <h3>${feature.properties.name}</h3> <p>Population: ${feature.properties.population.toLocaleString()}</p> ` ) .addTo(map); } }); map.on('mouseleave', 'states-layer', () => { map.getCanvas().style.cursor = ''; popup.remove(); }); }); ``` > **`step` vs `interpolate`:** The example above uses `interpolate` for smooth color gradients. For **discrete color buckets** (e.g., "low / medium / high"), use `['step', ['get', 'population'], '#f0f0f0', 500000, '#fee0d2', 2000000, '#fc9272', 10000000, '#de2d26']` instead. Prefer `step` when data has natural categories or when exact boundary values matter. **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 } }); }); ``` ## Best Practices ### Color Accessibility ```javascript // Use ColorBrewer scales for accessibility // https://colorbrewer2.org/ // Good: Sequential (single hue) const sequentialScale = ['#f0f9ff', '#bae4ff', '#7fcdff', '#0080ff', '#001f5c']; // Good: Diverging (two hues) const divergingScale = ['#d73027', '#fc8d59', '#fee08b', '#d9ef8b', '#91cf60', '#1a9850']; // Good: Qualitative (distinct categories) const qualitativeScale = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00']; // Avoid: Red-green for color-blind accessibility // Use: Blue-orange or purple-green instead ``` ### Error Handling ```javascript // Handle missing or invalid data map.on('load', () => { map.addSource('data', { type: 'geojson', data: dataUrl }); map.addLayer({ id: 'data-viz', type: 'fill', source: 'data', paint: { 'fill-color': [ 'case', ['has', 'value'], // Check if property exists ['interpolate', ['linear'], ['get', 'value'], 0, '#f0f0f0', 100, '#0080ff'], '#cccccc' // Default color for missing data ] } }); // Handle map errors map.on('error', (e) => { console.error('Map error:', e.error); }); }); ``` ## Data Size Rule - **< 1 MB**: Use GeoJSON directly - **1–10 MB**: Consider either GeoJSON or vector tiles depending on complexity - **> 10 MB**: Use vector tiles (upload to Mapbox as tileset) See [references/performance.md](references/performance.md) for implementation details. ## Reference Files For additional visualization patterns, load the relevant reference file: - **[references/clustering.md](references/clustering.md)** — Point clustering, custom cluster properties, clustering vs heatmap comparison - **[references/3d-extrusions.md](references/3d-extrusions.md)** — 3D building extrusions, custom data sources, data-driven heights - **[references/circles-lines.md](references/circles-lines.md)** — Circle/bubble maps, line data visualization, traffic flow styling - **[references/animation.md](references/animation.md)** — Time-series animation, real-time data updates, smooth transitions - **[references/performance.md](references/performance.md)** — Vector tiles vs GeoJSON, feature state, filtering, progressive loading - **[references/legends-use-cases.md](references/legends-use-cases.md)** — Legend UI, data inspector, data preprocessing, election/COVID/real-estate examples ## Resources - [Mapbox Expression Reference](https://docs.mapbox.com/style-spec/reference/expressions/) - [ColorBrewer](https://colorbrewer2.org/) - Color scales for maps - [Turf.js](https://turfjs.org/) - Spatial analysis - [Simple Statistics](https://simple-statistics.github.io/) - Data classification - [Data Visualization Tutorials](https://docs.mapbox.com/help/tutorials/#data-visualization)

path: AGENTS.md
content: # 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:**

<!-- prettier-ignore -->
```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              |
| ------- | ------------ | --------------------- |
| < 5 MB  | GeoJSON      | Direct load           |
| 5-20 MB | GeoJSON      | Consider vector tiles |
| > 20 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
<div class="legend">
  <h4>Population Density</h4>
  <div class="legend-item">
    <span class="legend-color" style="background: #f0f9ff;"></span>
    <span>0-500</span>
  </div>
  <div class="legend-item">
    <span class="legend-color" style="background: #0080ff;"></span>
    <span>1000+</span>
  </div>
</div>
```

## Common Use Cases

**Election Results:**

```javascript
'fill-color': [
  'match',
  ['get', 'winner'],
  'democrat', '#3b82f6',
  'republican', '#ef4444',
  '#94a3b8'
]
```

**COVID Cases:**

```javascript
'fill-color': [
  'step',
  ['/', ['get', 'cases'], ['get', 'population']],
  '#ffffb2',
  0.001, '#fed976',
  0.01, '#fc4e2a',
  0.1, '#b10026'
]
```

**Real Estate:**

```javascript
'circle-radius': [
  'interpolate',
  ['exponential', 2],
  ['get', 'price'],
  100000, 5,
  1000000, 20
],
'circle-color': [
  'interpolate',
  ['linear'],
  ['get', 'price_per_sqft'],
  0, '#ffffcc',
  400, '#41b6c4',
  800, '#253494'
]
```

## Quick Decisions

**Need to show regional statistics?**
→ Use choropleth with `fill` layer

**Need to show point density?**
→ Use `heatmap` layer (continuous) or clustering (discrete groups)

**Need to show point magnitude?**
→ Use `circle` layer with data-driven radius

**Need 3D visualization?**
→ Use `fill-extrusion` layer

**Need to animate over time?**
→ Use `setData()` with time-based filtering

**Large dataset (> 20 MB)?**
→ Use vector tiles instead of GeoJSON

**Need dynamic hover effects?**
→ Use feature state instead of updating data

**Color-blind friendly?**
→ Use blue-orange or purple-green, avoid red-green

## Expression Patterns

**Safe Property Access:**

```javascript
['case', ['has', 'property'], ['get', 'property'], defaultValue];
```

**Calculations:**

<!-- prettier-ignore -->
```javascript
// Divide
['/', ['get', 'numerator'], ['get', 'denominator']]

// Multiply
['*', ['get', 'value'], 1.5]

// Percentage
['*', ['/', ['get', 'part'], ['get', 'total']], 100]
```

## Resources

- [Mapbox Expression Reference](https://docs.mapbox.com/style-spec/reference/expressions/)
- [ColorBrewer](https://colorbrewer2.org/) - Accessible color scales
- [Turf.js](https://turfjs.org/) - Spatial analysis


path: evals/evals.json
content: {
  "skill_name": "mapbox-data-visualization-patterns",
  "evals": [
    {
      "id": 1,
      "prompt": "I'm building a choropleth map showing US population by state. States range from ~500K to ~40M people. What are my options for coloring states by population in Mapbox GL JS? I'm considering both smooth gradients and discrete color buckets.",
      "expectations": [
        "Uses the interpolate expression with linear interpolation: ['interpolate', ['linear'], ['get', 'population'], ...]",
        "Provides multiple stop values mapping population numbers to color values (at least 3-4 stops)",
        "Shows the complete addLayer pattern with type 'fill' and the interpolate expression in fill-color paint property",
        "Mentions that step expression is available as an alternative for discrete buckets, or explains when interpolate vs step is appropriate"
      ]
    },
    {
      "id": 2,
      "prompt": "I want to highlight individual countries on hover on my choropleth map — change the color when the mouse enters a feature and reset when it leaves. I don't want to re-upload GeoJSON on every hover event. What's the efficient pattern?",
      "expectations": [
        "Use feature state: set generateId: true in the GeoJSON source definition",
        "Call map.setFeatureState({ source: 'countries', id: hoveredId }, { hover: true }) on mousemove",
        "Reset with map.setFeatureState({ source: 'countries', id: hoveredId }, { hover: false }) on mouseleave",
        "Reference feature state in the layer paint with ['feature-state', 'hover'] expression (not re-uploading data)"
      ]
    },
    {
      "id": 3,
      "prompt": "I have datasets of varying sizes I need to visualize on Mapbox maps — some are small (500KB), some medium (5MB), and one is huge (50MB of GPS records). The 50MB one freezes when loaded as GeoJSON. What's the right approach for each size?",
      "expectations": [
        "50MB is well above the GeoJSON threshold — use vector tiles (MVT) instead",
        "The rule: <1MB use GeoJSON, 1-10MB consider either, >10MB use vector tiles",
        "Explains the source type change: type: 'vector' with a tiles URL, and requires source-layer in the addLayer call",
        "Vector tiles support progressive loading — only tiles in the current viewport are fetched, not all 50MB at once"
      ]
    }
  ]
}


path: references/3d-extrusions.md
content: # 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
]
```


path: references/animation.md
content: # 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]);
}
```


path: references/circles-lines.md
content: # Circle/Bubble Maps and Line Data Visualization

## 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(
        `
        <h3>Magnitude ${props.mag}</h3>
        <p>Depth: ${props.depth} km</p>
        <p>Time: ${new Date(props.time).toLocaleString()}</p>
      `
      )
      .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
    }
  });
});
```


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)