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 a Color Relief Layer in Flutter

Terrain tile endpoint not confirmed

The terrain/relief tile URLs below are placeholders. MapMetrics does not currently serve /terrain-rgb/ or /relief/ on gateway.mapmetrics-atlas.net — both return 404 (checked 2026-08-04). Substitute a raster-DEM source you actually have access to. The layer and source wiring shown here is correct regardless of where the tiles come from.

This tutorial shows how to add a color relief (hypsometric tinting) layer to your MapMetrics Flutter map — coloring terrain by elevation bands from green lowlands to white mountain peaks.

No Client-Side Elevation Color Ramp

MapMetrics Flutter's raster layer types are RasterStyleLayer (plain image tiles) and HillshadeStyleLayer (shaded relief from a DEM). There's no color-relief layer type and no raster-color/raster-value paint properties you can point at a DEM source to recolor it by elevation client-side — that's a newer MapLibre style-spec layer type this SDK doesn't expose yet.

The real way to get hypsometric tinting is to serve pre-tinted raster tiles — an ordinary XYZ/TileJSON raster tileset where the coloring-by-elevation was already baked in server-side — and display it with a plain RasterSource + RasterStyleLayer, optionally layered with a real HillshadeStyleLayer for shading on top of the DEM.

Basic Color Relief (Pre-Tinted Tiles)

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

class ColorReliefScreen extends StatefulWidget {
  const ColorReliefScreen({super.key});

  @override
  State<ColorReliefScreen> createState() => _ColorReliefScreenState();
}

class _ColorReliefScreenState extends State<ColorReliefScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Color Relief Layer')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              initCenter: Position(8.2275, 46.8182), // Swiss Alps
              initZoom: 8,
            ),
            onMapCreated: (controller) => mapController = controller,
            onStyleLoaded: (style) async {
              await style.addSource(
                const RasterSource(
                  id: 'color-relief-tiles',
                  // A tileset that is already hypsometrically tinted
                  // server-side -- ordinary image tiles, not a DEM.
                  tiles: [
                    'https://gateway.mapmetrics-atlas.net/relief/{z}/{x}/{y}.png',
                  ],
                  tileSize: 256,
                  attribution: 'MapMetrics',
                ),
              );
              await style.addLayer(
                const RasterStyleLayer(
                  id: 'color-relief',
                  sourceId: 'color-relief-tiles',
                  paint: {'raster-opacity': 0.6},
                ),
              );
            },
          ),
          // Elevation legend
          Positioned(
            bottom: 16,
            left: 16,
            child: Card(
              child: Padding(
                padding: const EdgeInsets.all(10),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text(
                      'Elevation',
                      style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
                    ),
                    const SizedBox(height: 6),
                    _elevRow(const Color(0xFF1a6e1a), '0 - 200m'),
                    _elevRow(const Color(0xFF4ca64c), '200 - 500m'),
                    _elevRow(const Color(0xFFb3d98c), '500 - 1000m'),
                    _elevRow(const Color(0xFFe6d96e), '1000 - 2000m'),
                    _elevRow(const Color(0xFFc9854c), '2000 - 3000m'),
                    _elevRow(const Color(0xFF8c5a2e), '3000 - 4000m'),
                    _elevRow(const Color(0xFFffffff), '4000m+'),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _elevRow(Color color, String label) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 1),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 16,
            height: 12,
            decoration: BoxDecoration(
              color: color,
              border: Border.all(color: Colors.grey[400]!, width: 0.5),
            ),
          ),
          const SizedBox(width: 6),
          Text(label, style: const TextStyle(fontSize: 11)),
        ],
      ),
    );
  }
}

The color bands themselves (which elevation maps to which color) are baked into the tile images by whatever process generates relief/{z}/{x}/{y}.png — Dart code here only controls whether the layer is shown and at what opacity.

Color Relief with Hillshade

Layer the tinted raster under a real HillshadeStyleLayer (reading an actual DEM source) for a topographic look — shading comes from the DEM, color comes from the pre-tinted tileset:

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

class ReliefHillshadeScreen extends StatefulWidget {
  const ReliefHillshadeScreen({super.key});

  @override
  State<ReliefHillshadeScreen> createState() => _ReliefHillshadeScreenState();
}

class _ReliefHillshadeScreenState extends State<ReliefHillshadeScreen> {
  MapController? mapController;
  StyleController? styleController;
  bool showRelief = true;
  bool showHillshade = true;
  double reliefOpacity = 0.5;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Relief + Hillshade')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              initCenter: Position(7.6586, 45.9763), // Matterhorn
              initZoom: 10,
            ),
            onMapCreated: (controller) => mapController = controller,
            onStyleLoaded: (style) async {
              styleController = style;
              await _addLayers();
            },
          ),
          // Controls
          Positioned(
            top: 16,
            right: 16,
            child: Card(
              child: Padding(
                padding: const EdgeInsets.all(10),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const Text('Layers', style: TextStyle(fontWeight: FontWeight.bold)),
                    Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Checkbox(
                          value: showRelief,
                          onChanged: (val) async {
                            setState(() => showRelief = val!);
                            final style = styleController;
                            if (style == null) return;
                            if (val!) {
                              await _addReliefLayer(style);
                            } else {
                              await style.removeLayer('color-relief');
                            }
                          },
                        ),
                        const Text('Color Relief', style: TextStyle(fontSize: 13)),
                      ],
                    ),
                    Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Checkbox(
                          value: showHillshade,
                          onChanged: (val) async {
                            setState(() => showHillshade = val!);
                            final style = styleController;
                            if (style == null) return;
                            if (val!) {
                              await style.addLayer(_hillshadeLayer);
                            } else {
                              await style.removeLayer('hillshade');
                            }
                          },
                        ),
                        const Text('Hillshade', style: TextStyle(fontSize: 13)),
                      ],
                    ),
                    const SizedBox(height: 4),
                    const Text('Relief opacity', style: TextStyle(fontSize: 12)),
                    SizedBox(
                      width: 150,
                      child: Slider(
                        value: reliefOpacity,
                        min: 0.1,
                        max: 1.0,
                        onChanged: (val) async {
                          setState(() => reliefOpacity = val);
                          final style = styleController;
                          if (style == null || !showRelief) return;
                          // No setPaintProperty -- remove and re-add.
                          await style.removeLayer('color-relief');
                          await _addReliefLayer(style);
                        },
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
          // Location presets
          Positioned(
            bottom: 16,
            left: 8,
            right: 8,
            child: SizedBox(
              height: 40,
              child: ListView(
                scrollDirection: Axis.horizontal,
                children: [
                  _locationChip('Matterhorn', Position(7.659, 45.976), 10),
                  const SizedBox(width: 6),
                  _locationChip('Mont Blanc', Position(6.865, 45.833), 10),
                  const SizedBox(width: 6),
                  _locationChip('Swiss Alps', Position(8.228, 46.818), 8),
                  const SizedBox(width: 6),
                  _locationChip('Dolomites', Position(11.844, 46.410), 10),
                  const SizedBox(width: 6),
                  _locationChip('Pyrenees', Position(0.041, 42.695), 8),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _locationChip(String name, Position point, double zoom) {
    return ActionChip(
      avatar: const Icon(Icons.terrain, size: 14),
      label: Text(name, style: const TextStyle(fontSize: 12)),
      onPressed: () {
        mapController?.animateCamera(center: point, zoom: zoom);
      },
    );
  }

  static const _hillshadeLayer = HillshadeStyleLayer(
    id: 'hillshade',
    sourceId: 'terrain-dem',
    paint: {'hillshade-shadow-color': '#473B24'},
  );

  Future<void> _addReliefLayer(StyleController style) async {
    await style.addLayer(
      RasterStyleLayer(
        id: 'color-relief',
        sourceId: 'color-relief-tiles',
        paint: {'raster-opacity': reliefOpacity},
      ),
    );
  }

  Future<void> _addLayers() async {
    final style = styleController;
    if (style == null) return;

    await style.addSource(
      const RasterDemSource(
        id: 'terrain-dem',
        tiles: [
          'https://gateway.mapmetrics-atlas.net/terrain-rgb/{z}/{x}/{y}.png',
        ],
        tileSize: 256,
      ),
    );
    await style.addSource(
      const RasterSource(
        id: 'color-relief-tiles',
        tiles: [
          'https://gateway.mapmetrics-atlas.net/relief/{z}/{x}/{y}.png',
        ],
        tileSize: 256,
      ),
    );

    // Hillshade first (below relief), then the tinted relief on top.
    await style.addLayer(_hillshadeLayer);
    await _addReliefLayer(style);
  }
}

Color Scheme Switcher

Since the coloring is baked into the tile images, switching "schemes" means pointing the source at a different pre-tinted tileset — remove both the source and the layer, then add them again with the new tiles URL:

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

class ReliefSchemesScreen extends StatefulWidget {
  const ReliefSchemesScreen({super.key});

  @override
  State<ReliefSchemesScreen> createState() => _ReliefSchemesScreenState();
}

class _ReliefSchemesScreenState extends State<ReliefSchemesScreen> {
  MapController? mapController;
  StyleController? styleController;
  String activeScheme = 'natural';
  bool _layersAdded = false;

  // Each scheme is a separately hosted, pre-tinted tileset.
  final schemes = const {
    'natural': (name: 'Natural', tileset: 'relief'),
    'ocean': (name: 'Ocean Blue', tileset: 'relief-ocean'),
    'thermal': (name: 'Thermal', tileset: 'relief-thermal'),
    'grayscale': (name: 'Grayscale', tileset: 'relief-grayscale'),
  };

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Relief Color Schemes')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8),
            child: Wrap(
              spacing: 8,
              children: schemes.entries.map((entry) {
                return ChoiceChip(
                  label: Text(entry.value.name),
                  selected: activeScheme == entry.key,
                  onSelected: (selected) {
                    if (selected) {
                      setState(() => activeScheme = entry.key);
                      _applyScheme(entry.value.tileset);
                    }
                  },
                );
              }).toList(),
            ),
          ),
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
                initCenter: Position(8.228, 46.818),
                initZoom: 8,
              ),
              onMapCreated: (controller) => mapController = controller,
              onStyleLoaded: (style) async {
                styleController = style;
                await _applyScheme(schemes[activeScheme]!.tileset);
              },
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _applyScheme(String tileset) async {
    final style = styleController;
    if (style == null) return;

    if (_layersAdded) {
      await style.removeLayer('color-relief');
      await style.removeSource('color-relief-tiles');
    }
    _layersAdded = true;

    await style.addSource(
      RasterSource(
        id: 'color-relief-tiles',
        tiles: ['https://gateway.mapmetrics-atlas.net/$tileset/{z}/{x}/{y}.png'],
        tileSize: 256,
      ),
    );
    await style.addLayer(
      const RasterStyleLayer(
        id: 'color-relief',
        sourceId: 'color-relief-tiles',
        paint: {'raster-opacity': 0.6},
      ),
    );
  }
}

Color Relief Properties

PropertyTypeDescription
raster-opacitydoubleLayer transparency (0.0 - 1.0), the one thing that's actually adjustable at runtime
tile coloringServer-sideElevation-to-color mapping is baked into the raster tiles, not computed client-side

Standard Elevation Color Bands

ElevationColorTerrain Type
0 - 200mDark greenLowlands, valleys
200 - 500mGreenHills, foothills
500 - 1000mLight greenUplands
1000 - 2000mYellow-greenMountains
2000 - 3000mBrownHigh mountains
3000 - 4000mDark brownAlpine zone
4000m+WhiteGlaciers, snow

These bands describe how your tile-generation pipeline should tint the source imagery — they aren't something the Flutter app configures at runtime.

Next Steps


Tip: Combine color relief + hillshade for a complete topographic look. Set the relief layer's raster-opacity to 0.4-0.6 so the hillshade underneath (and any base map labels) remain visible.