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.
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.