Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Add Contour Lines in Flutter

This tutorial shows how to add elevation contour lines to your MapMetrics Flutter map — essential for topographic maps, hiking apps, and geographic analysis.

Prerequisites

Before you begin, ensure you have:

Basic Contour Lines

Add contour lines from a vector tile source. Sources and layers are added through the StyleController you receive in onStyleLoaded — call addSource before addLayer, since a layer references its source by id:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class ContourLinesScreen extends StatefulWidget {
  @override
  _ContourLinesScreenState createState() => _ContourLinesScreenState();
}

class _ContourLinesScreenState extends State<ContourLinesScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Contour Lines')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(8.2275, 46.8182), // Swiss Alps (lng, lat)
          initZoom: 11.0,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (MapController controller) {
          mapController = controller;
        },
        onStyleLoaded: (StyleController style) {
          _addContourLines(style);
        },
      ),
    );
  }

  Future<void> _addContourLines(StyleController style) async {
    // Add contour source (vector tiles with elevation data)
    await style.addSource(
      const VectorSource(
        id: 'contour-source',
        tiles: ['https://gateway.mapmetrics-atlas.net/contours/{z}/{x}/{y}.pbf'],
        sourceLayer: 'contour',
      ),
    );

    // Add contour lines
    await style.addLayer(
      const LineStyleLayer(
        id: 'contour-lines',
        sourceId: 'contour-source',
        paint: {
          'line-color': '#8B4513',
          'line-width': 0.75,
          'line-opacity': 0.6,
        },
      ),
    );

    // Add elevation labels, limited to index (major) contours
    await style.addLayer(
      const SymbolStyleLayer(
        id: 'contour-labels',
        sourceId: 'contour-source',
        layout: {
          'text-field': '{ele}m',
          'text-size': 10.0,
          'symbol-placement': 'line',
        },
        paint: {'text-color': '#8B4513'},
        filter: ['==', ['%', ['get', 'ele'], 500], 0],
      ),
    );
  }
}

Note on minor vs. major contours: SymbolStyleLayer accepts a filter, so the elevation labels above are correctly limited to index (500 m) contours. LineStyleLayer in the current SDK release only exposes layout and paint — it does not yet forward filter, minZoom, or maxZoom — so you cannot style minor and major contour lines differently from a single vector source with a modulo filter. If you need visually distinct minor/major contour lines, ask your tile provider to publish them as two separate source layers (for example contour_minor and contour_major) and add one VectorSource + LineStyleLayer pair per source layer.

Contour Lines with Hillshade

Combine contour lines with hillshade for a classic topographic map. Because the real SDK has no setLayerVisibility call, layer toggles are implemented by adding or removing the layer itself with StyleController.addLayer / removeLayer:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class TopoMapScreen extends StatefulWidget {
  @override
  _TopoMapScreenState createState() => _TopoMapScreenState();
}

class _TopoMapScreenState extends State<TopoMapScreen> {
  MapController? mapController;
  StyleController? styleController;
  bool showContours = true;
  bool showHillshade = true;

  static const _hillshadeLayer = HillshadeStyleLayer(
    id: 'hillshade-layer',
    sourceId: 'dem-source',
    paint: {
      'hillshade-exaggeration': 0.4,
      'hillshade-illumination-direction': 315.0,
    },
  );

  static const _contourLinesLayer = LineStyleLayer(
    id: 'contour-lines',
    sourceId: 'contour-source',
    paint: {
      'line-color': '#8B4513',
      'line-width': 0.5,
      'line-opacity': 0.4,
    },
  );

  static const _contourLabelsLayer = SymbolStyleLayer(
    id: 'contour-labels',
    sourceId: 'contour-source',
    layout: {
      'text-field': '{ele}m',
      'text-size': 9.0,
      'symbol-placement': 'line',
    },
    paint: {'text-color': '#654321'},
    filter: ['==', ['%', ['get', 'ele'], 500], 0],
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Topographic Map')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(7.6586, 45.9763), // Matterhorn (lng, lat)
              initZoom: 12.0,
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
            ),
            onMapCreated: (MapController controller) {
              mapController = controller;
            },
            onStyleLoaded: (StyleController style) {
              styleController = style;
              _addLayers(style);
            },
          ),
          // Layer toggles
          Positioned(
            top: 16,
            right: 16,
            child: Card(
              child: Padding(
                padding: EdgeInsets.all(8),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Checkbox(
                          value: showHillshade,
                          onChanged: (val) {
                            setState(() => showHillshade = val!);
                            _toggleLayer(_hillshadeLayer, val!);
                          },
                        ),
                        Text('Hillshade', style: TextStyle(fontSize: 13)),
                      ],
                    ),
                    Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Checkbox(
                          value: showContours,
                          onChanged: (val) {
                            setState(() => showContours = val!);
                            _toggleLayer(_contourLinesLayer, val!);
                            _toggleLayer(_contourLabelsLayer, val!);
                          },
                        ),
                        Text('Contours', style: TextStyle(fontSize: 13)),
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _addLayers(StyleController style) async {
    // Hillshade
    await style.addSource(
      const RasterDemSource(
        id: 'dem-source',
        tiles: ['https://gateway.mapmetrics-atlas.net/terrain/{z}/{x}/{y}.png'],
        tileSize: 256,
      ),
    );
    await style.addLayer(_hillshadeLayer);

    // Contour lines
    await style.addSource(
      const VectorSource(
        id: 'contour-source',
        tiles: ['https://gateway.mapmetrics-atlas.net/contours/{z}/{x}/{y}.pbf'],
        sourceLayer: 'contour',
      ),
    );
    await style.addLayer(_contourLinesLayer);
    await style.addLayer(_contourLabelsLayer);
  }

  Future<void> _toggleLayer(StyleLayer layer, bool visible) async {
    final style = styleController;
    if (style == null) return;
    if (visible) {
      await style.addLayer(layer);
    } else {
      await style.removeLayer(layer.id);
    }
  }
}

Contour Line Properties

PropertyValueDescription
line-color#8B4513Brown for topographic convention
line-width0.5 – 0.75Set uniformly; per-layer minor/major widths need separate source layers (see note above)
line-opacity0.4 – 0.6Semi-transparent to not obscure the base map
filter (labels only)['==', ['%', ['get', 'ele'], 500], 0]Show elevation labels only every 500m

Next Steps


Tip: Use brown (#8B4513) for contour lines — it's the cartographic standard. Keep contour lines thin and semi-transparent so they don't obscure the base map, and reserve filter-driven styling (elevation labels, index contours) for SymbolStyleLayer, which is the layer type that currently supports it.