Skip to content

Add Contour Lines in Maptoolkit Maps JS

Contour lines show elevation as lines of constant height, which is how you read the shape and steepness of terrain at a glance. Maptoolkit serves them as a vector tile source, so you add them as a second source and style them yourself. Use contours in hiking, cycling and mountain applications, where the gradient between two points matters more than the distance.

const API_KEY = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [11.3782, 47.3095],
        zoom: 14,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    map.on('load', () => {
        map.addSource('mtk-contours', {
            type: 'vector',
            url: `https://dataconnector.maptoolkit.net/maptoolkit/mtk-contours-bathymetry.json?api_key=${API_KEY}`
        });

        // Draw the contours under the basemap's own labels so place names stay on top.
        const firstSymbolId = map.getStyle().layers.find((l) => l.type === 'symbol')?.id;

        // Every contour, thin.
        map.addLayer({
            id: 'contours',
            type: 'line',
            source: 'mtk-contours',
            'source-layer': 'contours',
            minzoom: 11,
            paint: {
                'line-color': '#8a6a4a',
                'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.3, 15, 0.8],
                'line-opacity': ['interpolate', ['linear'], ['zoom'], 11, 0, 12.5, 0.65]
            }
        }, firstSymbolId);

        // Index contours, drawn heavier so the eye can count between them. `divisor` is
        // the roundest number that divides the height, so the threshold picks out every
        // fifth line or so at each zoom, where the interval itself changes.
        const isIndexContour = ['>=', ['get', 'divisor'], ['step', ['zoom'], 500, 12, 200, 14, 100]];

        map.addLayer({
            id: 'contours-index',
            type: 'line',
            source: 'mtk-contours',
            'source-layer': 'contours',
            minzoom: 11,
            filter: isIndexContour,
            paint: {
                'line-color': '#7a5533',
                'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.7, 15, 1.8],
                'line-opacity': ['interpolate', ['linear'], ['zoom'], 11, 0, 12.5, 0.85]
            }
        }, firstSymbolId);

        // Heights along the index contours. Appended last, so it sits ABOVE the basemap's
        // labels: below them it loses every collision and almost nothing is drawn.
        map.addLayer({
            id: 'contours-label',
            type: 'symbol',
            source: 'mtk-contours',
            'source-layer': 'contours',
            minzoom: 12,
            filter: isIndexContour,
            layout: {
                'symbol-placement': 'line',
                'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'],
                'text-font': ['Roboto Regular'],
                'text-size': 10,
                'text-max-angle': 60,
                'text-padding': 2,
                'symbol-spacing': 120
            },
            paint: {
                'text-color': '#6b4a2b',
                'text-halo-color': '#ffffff',
                'text-halo-width': 1.4
            }
        });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Add Contour Lines - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Draw labelled contour lines from the contours vector tile source." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
    </style>
</head>
<body>
<div id="map"></div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [11.3782, 47.3095],
        zoom: 14,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    map.on('load', () => {
        map.addSource('mtk-contours', {
            type: 'vector',
            url: `https://dataconnector.maptoolkit.net/maptoolkit/mtk-contours-bathymetry.json?api_key=${API_KEY}`
        });

        // Draw the contours under the basemap's own labels so place names stay on top.
        const firstSymbolId = map.getStyle().layers.find((l) => l.type === 'symbol')?.id;

        // Every contour, thin.
        map.addLayer({
            id: 'contours',
            type: 'line',
            source: 'mtk-contours',
            'source-layer': 'contours',
            minzoom: 11,
            paint: {
                'line-color': '#8a6a4a',
                'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.3, 15, 0.8],
                'line-opacity': ['interpolate', ['linear'], ['zoom'], 11, 0, 12.5, 0.65]
            }
        }, firstSymbolId);

        // Index contours, drawn heavier so the eye can count between them. `divisor` is
        // the roundest number that divides the height, so the threshold picks out every
        // fifth line or so at each zoom, where the interval itself changes.
        const isIndexContour = ['>=', ['get', 'divisor'], ['step', ['zoom'], 500, 12, 200, 14, 100]];

        map.addLayer({
            id: 'contours-index',
            type: 'line',
            source: 'mtk-contours',
            'source-layer': 'contours',
            minzoom: 11,
            filter: isIndexContour,
            paint: {
                'line-color': '#7a5533',
                'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.7, 15, 1.8],
                'line-opacity': ['interpolate', ['linear'], ['zoom'], 11, 0, 12.5, 0.85]
            }
        }, firstSymbolId);

        // Heights along the index contours. Appended last, so it sits ABOVE the basemap's
        // labels: below them it loses every collision and almost nothing is drawn.
        map.addLayer({
            id: 'contours-label',
            type: 'symbol',
            source: 'mtk-contours',
            'source-layer': 'contours',
            minzoom: 12,
            filter: isIndexContour,
            layout: {
                'symbol-placement': 'line',
                'text-field': ['concat', ['to-string', ['get', 'ele']], ' m'],
                'text-font': ['Roboto Regular'],
                'text-size': 10,
                'text-max-angle': 60,
                'text-padding': 2,
                'symbol-spacing': 120
            },
            paint: {
                'text-color': '#6b4a2b',
                'text-halo-color': '#ffffff',
                'text-halo-width': 1.4
            }
        });
    });
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map with zoom level 13, centered around [11.3782, 47.3095] on the steep north face of the Nordkette, displaying elevation contour lines from the Maptoolkit contours vector tiles. Draw every 100 m contour heavier as an index contour and label it with its height.

How it works

Contours are a vector tile source, not something derived in the browser from the terrain DEM. They are served from the same TileJSON as bathymetry, so one addSource gives you both, and each layer picks between them with source-layer. Naming a source-layer that does not exist renders nothing and reports nothing, which is the usual reason a contour layer comes up empty.

A contour feature carries three fields: ele, the height in metres; divisor, the roundest number that divides that height; and terrain_type, which is normal or rock.

The interval already scales with zoom. The tiles serve 100 m contours at zoom 11, 50 m at 12 and 13, 20 m at 14 and 10 m at 15, so you do not filter the density yourself. This is the part that catches people out: a % 100 filter that looks correct at zoom 11, where it matches every line, quietly becomes every tenth line at zoom 15.

divisor is what makes an index contour possible across that changing interval. At 1000 m it is 1000, at 1900 m it is 100, at 1950 m it is 50. Filtering on it with a step over zoom keeps roughly every fifth line heavy no matter which interval is in play, and the whole rule is one expression reused by both the heavy line layer and the label layer.

symbol-placement: 'line' writes the label along the contour instead of at a point. It exists only on symbol layers.

text-max-angle is the setting that decides whether you see any heights at all, and the usual advice to keep it tight is wrong here. It caps the angle the text may turn through between characters, and a contour in mountain terrain turns constantly. At the 25 degrees that suits a street name, this example rendered zero labels: every candidate position was rejected as too curved. At 60 it renders about fifteen on the same screen. If contour heights are missing and nothing is logged, this is why. symbol-spacing then sets how often the height repeats along one line.

Layer order is split deliberately, and this is the detail worth copying. The two line layers go below the basemap’s labels via firstSymbolId, so place names and road numbers stay readable over them. The label layer is appended last, above everything. MapLibre resolves label collisions in layer order, so a contour label placed below the basemap’s symbols loses to every place name near it: with the label layer under firstSymbolId this example rendered one or two heights on the whole screen.

Next steps

Relief shading under the lines is the usual pairing, because hillshading shows the shape of the ground immediately while the contours supply the numbers.

For heights at specific points rather than along lines, the Elevation API returns metres for any coordinate. That is what labels a summit, and what turns a route into a climb profile.