Skip to content

Add a Legend for a Data-Driven Layer in Maptoolkit Maps JS

A data-driven map that does not explain its colours is decoration. The usual fix is to hand-write a legend in HTML, which works until someone changes a colour in the style and forgets the legend. This example derives the legend from the same array of stops that builds the paint expression, so there is one definition and the two cannot disagree.

const API_KEY = 'YOUR_API_KEY';

    // One definition. The expression and the legend are both built from this.
    const STOPS = [
        { from: 0, color: '#fee5d9', label: 'under 2' },
        { from: 2, color: '#fcae91', label: '2 to 3' },
        { from: 3, color: '#fb6a4a', label: '3 to 4' },
        { from: 4, color: '#de2d26', label: '4 to 5' },
        { from: 5, color: '#a50f15', label: '5 and above' }
    ];

    // step wants: [step, input, <value below first stop>, stop1, value1, stop2, value2, ...]
    function stepExpression(property, stops) {
        const expression = ['step', ['get', property], stops[0].color];
        for (const stop of stops.slice(1)) expression.push(stop.from, stop.color);
        return expression;
    }

    function renderLegend(title, stops) {
        document.getElementById('legend').innerHTML =
            `<h4>${title}</h4>` +
            stops.map(s => `<div class="row"><span class="sw" style="background:${s.color}"></span><span>${s.label}</span></div>`).join('');
    }

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

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

    map.on('load', () => {
        map.addSource('earthquakes', {
            type: 'geojson',
            data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson'
        });

        map.addLayer({
            id: 'earthquakes',
            type: 'circle',
            source: 'earthquakes',
            paint: {
                'circle-color': stepExpression('mag', STOPS),
                'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 14],
                'circle-stroke-color': '#fff',
                'circle-stroke-width': 1,
                'circle-opacity': 0.9
            }
        });

        renderLegend('Magnitude', STOPS);
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Add a Legend - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Build a legend from the same stops that colour the layer." />
    <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%; }
        .mtk-legend {
            position: absolute; bottom: 30px; right: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 12px/1.5 system-ui, sans-serif; padding: 10px 12px;
        }
        .mtk-legend h4 { margin: 0 0 6px; font-size: 12px; font-weight: 600; }
        .mtk-legend .row { display: flex; align-items: center; gap: 8px; }
        .mtk-legend .sw { width: 12px; height: 12px; border-radius: 50%; flex: none; border: 1px solid #0003; }
    </style>
</head>
<body>
<div id="map"></div>
<div class="mtk-legend" id="legend"></div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    // One definition. The expression and the legend are both built from this.
    const STOPS = [
        { from: 0, color: '#fee5d9', label: 'under 2' },
        { from: 2, color: '#fcae91', label: '2 to 3' },
        { from: 3, color: '#fb6a4a', label: '3 to 4' },
        { from: 4, color: '#de2d26', label: '4 to 5' },
        { from: 5, color: '#a50f15', label: '5 and above' }
    ];

    // step wants: [step, input, <value below first stop>, stop1, value1, stop2, value2, ...]
    function stepExpression(property, stops) {
        const expression = ['step', ['get', property], stops[0].color];
        for (const stop of stops.slice(1)) expression.push(stop.from, stop.color);
        return expression;
    }

    function renderLegend(title, stops) {
        document.getElementById('legend').innerHTML =
            `<h4>${title}</h4>` +
            stops.map(s => `<div class="row"><span class="sw" style="background:${s.color}"></span><span>${s.label}</span></div>`).join('');
    }

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

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

    map.on('load', () => {
        map.addSource('earthquakes', {
            type: 'geojson',
            data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson'
        });

        map.addLayer({
            id: 'earthquakes',
            type: 'circle',
            source: 'earthquakes',
            paint: {
                'circle-color': stepExpression('mag', STOPS),
                'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 14],
                'circle-stroke-color': '#fff',
                'circle-stroke-width': 1,
                'circle-opacity': 0.9
            }
        });

        renderLegend('Magnitude', STOPS);
    });
</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 earthquake data coloured by magnitude using a step expression, and generate a legend from the same stops used to build the expression.

How it works

STOPS is the only place the encoding is written down. stepExpression turns it into a MapLibre step expression and renderLegend turns it into swatches, so adding a class or changing a colour touches one array and both follow.

The shape of step is the part worth knowing. It is ['step', input, valueBelowFirstStop, stop1, value1, stop2, value2, ...], so the first colour is not paired with a number. It is the value used below the first threshold. That asymmetry is why the builder starts the expression with stops[0].color and then iterates from stops.slice(1). Writing it as evenly paired arguments produces an expression that either fails validation or silently shifts every band by one.

step is the right choice here rather than interpolate. Interpolation implies the values in between mean something, and a legend with five discrete swatches then lies about a continuous ramp. Use step when the legend has rows and interpolate when it has a gradient bar.

Labels are written out rather than generated from the numbers, because the last band has no upper bound and the first has no lower one. Generating “0 to 2, 2 to 3” from the array is easy; generating “5 and above” for the final row and getting the boundary wording right is where it stops being worth it.

The legend sits in ordinary DOM outside the map rather than in a control. That keeps it styleable with the rest of the page and out of the way of the attribution, which must stay visible.

Next steps

A legend belongs anywhere colour carries meaning, and the same array drives more than one layer: reusing STOPS for a circle-color and a matching line-color keeps a point layer and its connecting lines in step.

For a continuous ramp rather than bands, swap step for interpolate and render the legend as a CSS gradient with a few labelled ticks. The Weather API layers are the usual case, where a temperature scale has to stay fixed rather than adapting to what is on screen, or the same colour means something different on each load.