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)
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:
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:
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
| Property | Type | Description |
|---|---|---|
raster-opacity | double | Layer transparency (0.0 - 1.0), the one thing that's actually adjustable at runtime |
| tile coloring | Server-side | Elevation-to-color mapping is baked into the raster tiles, not computed client-side |
Standard Elevation Color Bands
| Elevation | Color | Terrain Type |
|---|---|---|
| 0 - 200m | Dark green | Lowlands, valleys |
| 200 - 500m | Green | Hills, foothills |
| 500 - 1000m | Light green | Uplands |
| 1000 - 2000m | Yellow-green | Mountains |
| 2000 - 3000m | Brown | High mountains |
| 3000 - 4000m | Dark brown | Alpine zone |
| 4000m+ | White | Glaciers, 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
- 3D Terrain — Hillshade relief and camera pitch
- 3D Buildings — Extruded building layers
- Add a Cluster — Native GeoJSON clustering
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.