Skip to content

Choose an Icon per Feature in Maptoolkit Maps JS

One icon for everything is rarely enough, and a layer per category does not scale. icon-image accepts an expression, so a single symbol layer can pick the right image for each feature from one of its properties. This example registers several icons, selects between them by category, and falls back to a default for anything unrecognised.

const API_KEY = 'YOUR_API_KEY';

    // Icons drawn on a canvas keep the example self-contained. In an application these are
    // usually one sprite sheet, or PNGs served from your own origin.
    const SIZE = 48;

    function icon(color, shape) {
        const canvas = document.createElement('canvas');
        canvas.width = canvas.height = SIZE;
        const ctx = canvas.getContext('2d');
        const c = SIZE / 2, r = SIZE / 2 - 5;

        ctx.beginPath();
        if (shape === 'triangle') {
            ctx.moveTo(c, c - r); ctx.lineTo(c + r, c + r); ctx.lineTo(c - r, c + r);
        } else if (shape === 'circle') {
            ctx.arc(c, c, r, 0, Math.PI * 2);
        } else if (shape === 'diamond') {
            ctx.moveTo(c, c - r); ctx.lineTo(c + r, c); ctx.lineTo(c, c + r); ctx.lineTo(c - r, c);
        } else {
            ctx.rect(c - r, c - r, r * 2, r * 2);
        }
        ctx.closePath();

        ctx.fillStyle = color;
        ctx.fill();
        ctx.lineWidth = 4;
        ctx.strokeStyle = '#fff';
        ctx.stroke();

        // addImage takes raw pixels, so there is no decoding step and nothing to await.
        return ctx.getImageData(0, 0, SIZE, SIZE);
    }

    const ICONS = {
        hut:       icon('#c0392b', 'triangle'),
        viewpoint: icon('#2980b9', 'circle'),
        spring:    icon('#16a085', 'diamond'),
        default:   icon('#7f8c8d', 'square')
    };

    const DATA = {
        type: 'FeatureCollection',
        features: [
            ['hut', 'Pfeishütte', 11.400, 47.325], ['hut', 'Solsteinhaus', 11.270, 47.315],
            ['viewpoint', 'Hafelekar', 11.381, 47.312], ['viewpoint', 'Seegrube', 11.379, 47.301],
            ['spring', 'Mühlauer Quelle', 11.416, 47.283],
            ['cablecar', 'Nordkettenbahn', 11.394, 47.290]
        ].map(([category, name, lng, lat]) => ({
            type: 'Feature',
            properties: { category, name },
            geometry: { type: 'Point', coordinates: [lng, lat] }
        }))
    };

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

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

    map.on('load', () => {
        // Every image has to be registered before the layer that names it is added.
        for (const [name, image] of Object.entries(ICONS)) {
            if (!map.hasImage(`icon-${name}`)) map.addImage(`icon-${name}`, image);
        }

        map.addSource('places', { type: 'geojson', data: DATA });

        map.addLayer({
            id: 'places',
            type: 'symbol',
            source: 'places',
            layout: {
                // The last argument is the fallback for anything not listed.
                'icon-image': ['match', ['get', 'category'],
                    'hut', 'icon-hut',
                    'viewpoint', 'icon-viewpoint',
                    'spring', 'icon-spring',
                    'icon-default'
                ],
                'icon-size': 0.5,
                'icon-anchor': 'bottom',
                'icon-allow-overlap': true,
                'text-field': ['get', 'name'],
                'text-font': ['Roboto Regular'],
                'text-size': 11,
                'text-offset': [0, 0.4],
                'text-anchor': 'top'
            },
            paint: { 'text-color': '#333', 'text-halo-color': '#fff', 'text-halo-width': 1.4 }
        });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Icon per Feature - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Pick an icon per feature from a property." />
    <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';

    // Icons drawn on a canvas keep the example self-contained. In an application these are
    // usually one sprite sheet, or PNGs served from your own origin.
    const SIZE = 48;

    function icon(color, shape) {
        const canvas = document.createElement('canvas');
        canvas.width = canvas.height = SIZE;
        const ctx = canvas.getContext('2d');
        const c = SIZE / 2, r = SIZE / 2 - 5;

        ctx.beginPath();
        if (shape === 'triangle') {
            ctx.moveTo(c, c - r); ctx.lineTo(c + r, c + r); ctx.lineTo(c - r, c + r);
        } else if (shape === 'circle') {
            ctx.arc(c, c, r, 0, Math.PI * 2);
        } else if (shape === 'diamond') {
            ctx.moveTo(c, c - r); ctx.lineTo(c + r, c); ctx.lineTo(c, c + r); ctx.lineTo(c - r, c);
        } else {
            ctx.rect(c - r, c - r, r * 2, r * 2);
        }
        ctx.closePath();

        ctx.fillStyle = color;
        ctx.fill();
        ctx.lineWidth = 4;
        ctx.strokeStyle = '#fff';
        ctx.stroke();

        // addImage takes raw pixels, so there is no decoding step and nothing to await.
        return ctx.getImageData(0, 0, SIZE, SIZE);
    }

    const ICONS = {
        hut:       icon('#c0392b', 'triangle'),
        viewpoint: icon('#2980b9', 'circle'),
        spring:    icon('#16a085', 'diamond'),
        default:   icon('#7f8c8d', 'square')
    };

    const DATA = {
        type: 'FeatureCollection',
        features: [
            ['hut', 'Pfeishütte', 11.400, 47.325], ['hut', 'Solsteinhaus', 11.270, 47.315],
            ['viewpoint', 'Hafelekar', 11.381, 47.312], ['viewpoint', 'Seegrube', 11.379, 47.301],
            ['spring', 'Mühlauer Quelle', 11.416, 47.283],
            ['cablecar', 'Nordkettenbahn', 11.394, 47.290]
        ].map(([category, name, lng, lat]) => ({
            type: 'Feature',
            properties: { category, name },
            geometry: { type: 'Point', coordinates: [lng, lat] }
        }))
    };

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

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

    map.on('load', () => {
        // Every image has to be registered before the layer that names it is added.
        for (const [name, image] of Object.entries(ICONS)) {
            if (!map.hasImage(`icon-${name}`)) map.addImage(`icon-${name}`, image);
        }

        map.addSource('places', { type: 'geojson', data: DATA });

        map.addLayer({
            id: 'places',
            type: 'symbol',
            source: 'places',
            layout: {
                // The last argument is the fallback for anything not listed.
                'icon-image': ['match', ['get', 'category'],
                    'hut', 'icon-hut',
                    'viewpoint', 'icon-viewpoint',
                    'spring', 'icon-spring',
                    'icon-default'
                ],
                'icon-size': 0.5,
                'icon-anchor': 'bottom',
                'icon-allow-overlap': true,
                'text-field': ['get', 'name'],
                'text-font': ['Roboto Regular'],
                'text-size': 11,
                'text-offset': [0, 0.4],
                'text-anchor': 'top'
            },
            paint: { 'text-color': '#333', 'text-halo-color': '#fff', '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 points of several categories, registering one icon per category and selecting between them in a single symbol layer with a match expression on the category property.

How it works

icon-image names an image in the map’s own image registry, so every image has to be added with addImage before the layer referencing it is created. Adding the layer first produces a console warning per missing image and a layer that draws only labels, which reads as the icons silently failing.

The icons here are drawn on a canvas and registered as ImageData, which addImage accepts directly. That keeps the whole step synchronous, so there is no ordering problem to manage. Loading images from URLs instead means map.loadImage, which returns a promise in current MapLibre and has to resolve before addLayer runs. Note that loadImage will not take an SVG data URI: it fails with The source image could not be decoded, so use PNG, or a sprite sheet.

The match expression takes a fallback as its final argument, with no value paired to it. That is what icon-default is doing at the end, and it is why the cablecar point in the data still renders: a category nobody anticipated gets the generic marker instead of vanishing. A match without a fallback throws when it meets an unlisted value.

icon-allow-overlap: true is set because these markers are the point of the map. Left at the default, MapLibre drops icons that collide, which is right for basemap labels and wrong for your own data, where a missing pin looks like a missing record. The trade is clutter at low zoom, usually handled with minzoom or clustering rather than by letting collisions decide.

icon-anchor: 'bottom' puts the base of the symbol on the coordinate. The default is center, which floats it half a symbol above where the thing actually is.

icon-size: 0.5 against 48 pixel artwork renders at 24 and stays sharp on high-density screens. Drawing 24 pixel artwork at 1 looks soft on every modern phone.

Next steps

Size and colour can be driven from the data at the same time. icon-size takes an interpolate over a numeric property, which turns the icon set into a symbol map where the shape says what and the size says how much.

Once several categories share a map, the obvious companion is a filter that shows one at a time, and a legend built from the same category list that generated the match expression.