Filter Features by Category in Maptoolkit Maps JS
Almost every map with more than one kind of thing on it ends up needing a “show me only X” control. Adding a layer per category works until there are eight of them. The scalable version is one source, one layer, and a filter expression rebuilt from whatever is currently ticked.
const API_KEY = 'YOUR_API_KEY';
const CATEGORIES = {
hut: { label: 'Huts', singular: 'Hut', color: '#c0392b' },
viewpoint: { label: 'Viewpoints', singular: 'Viewpoint', color: '#2980b9' },
spring: { label: 'Springs', singular: 'Spring', color: '#16a085' },
parking: { label: 'Parking', singular: 'Car park', color: '#7f8c8d' }
};
// Stand-in for your own data.
const DATA = {
type: 'FeatureCollection',
features: [
['hut', 11.380, 47.300], ['hut', 11.420, 47.315], ['hut', 11.345, 47.292],
['viewpoint', 11.400, 47.308], ['viewpoint', 11.363, 47.284], ['viewpoint', 11.437, 47.297],
['spring', 11.392, 47.276], ['spring', 11.410, 47.288],
['parking', 11.375, 47.268], ['parking', 11.428, 47.272], ['parking', 11.352, 47.264]
].map(([category, lng, lat], i) => ({
type: 'Feature',
properties: { category, name: `${CATEGORIES[category].singular} ${i + 1}` },
geometry: { type: 'Point', coordinates: [lng, lat] }
}))
};
const active = new Set(Object.keys(CATEGORIES));
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.39, 47.29],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
function applyFilter() {
// An empty set must hide everything, so the false literal is the base case.
const filter = active.size === 0
? ['boolean', false]
: ['in', ['get', 'category'], ['literal', [...active]]];
map.setFilter('points', filter);
map.setFilter('points-label', filter);
}
function buildControls() {
const counts = {};
for (const f of DATA.features) counts[f.properties.category] = (counts[f.properties.category] || 0) + 1;
document.getElementById('filters').innerHTML = Object.entries(CATEGORIES)
.map(([key, c]) => `
<label>
<input type="checkbox" value="${key}" checked>
<span class="sw" style="background:${c.color}"></span>
<span>${c.label}</span>
<span class="count">${counts[key] || 0}</span>
</label>`)
.join('');
document.getElementById('filters').addEventListener('change', (e) => {
const key = e.target.value;
e.target.checked ? active.add(key) : active.delete(key);
applyFilter();
});
}
map.on('load', () => {
map.addSource('places', { type: 'geojson', data: DATA });
const colorByCategory = ['match', ['get', 'category'],
...Object.entries(CATEGORIES).flatMap(([key, c]) => [key, c.color]),
'#999999'
];
map.addLayer({
id: 'points',
type: 'circle',
source: 'places',
paint: {
'circle-radius': 7,
'circle-color': colorByCategory,
'circle-stroke-color': '#fff',
'circle-stroke-width': 2
}
});
map.addLayer({
id: 'points-label',
type: 'symbol',
source: 'places',
layout: {
'text-field': ['get', 'name'],
'text-font': ['Roboto Regular'],
'text-size': 11,
'text-offset': [0, 1.2],
'text-anchor': 'top'
},
paint: { 'text-color': '#333', 'text-halo-color': '#fff', 'text-halo-width': 1.2 }
});
buildControls();
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Filter by Category - Maptoolkit Maps JS</title>
<meta property="og:description" content="Filter one source by category with checkboxes." />
<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%; }
#filters {
position: absolute; top: 10px; left: 10px; z-index: 999;
background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
font: 13px/1.6 system-ui, sans-serif; padding: 10px 12px;
}
#filters label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
#filters .sw { width: 12px; height: 12px; border-radius: 50%; flex: none; }
#filters .count { margin-left: auto; color: #777; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id="map"></div>
<div id="filters"></div>
<script>
const API_KEY = 'YOUR_API_KEY';
const CATEGORIES = {
hut: { label: 'Huts', singular: 'Hut', color: '#c0392b' },
viewpoint: { label: 'Viewpoints', singular: 'Viewpoint', color: '#2980b9' },
spring: { label: 'Springs', singular: 'Spring', color: '#16a085' },
parking: { label: 'Parking', singular: 'Car park', color: '#7f8c8d' }
};
// Stand-in for your own data.
const DATA = {
type: 'FeatureCollection',
features: [
['hut', 11.380, 47.300], ['hut', 11.420, 47.315], ['hut', 11.345, 47.292],
['viewpoint', 11.400, 47.308], ['viewpoint', 11.363, 47.284], ['viewpoint', 11.437, 47.297],
['spring', 11.392, 47.276], ['spring', 11.410, 47.288],
['parking', 11.375, 47.268], ['parking', 11.428, 47.272], ['parking', 11.352, 47.264]
].map(([category, lng, lat], i) => ({
type: 'Feature',
properties: { category, name: `${CATEGORIES[category].singular} ${i + 1}` },
geometry: { type: 'Point', coordinates: [lng, lat] }
}))
};
const active = new Set(Object.keys(CATEGORIES));
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.39, 47.29],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
function applyFilter() {
// An empty set must hide everything, so the false literal is the base case.
const filter = active.size === 0
? ['boolean', false]
: ['in', ['get', 'category'], ['literal', [...active]]];
map.setFilter('points', filter);
map.setFilter('points-label', filter);
}
function buildControls() {
const counts = {};
for (const f of DATA.features) counts[f.properties.category] = (counts[f.properties.category] || 0) + 1;
document.getElementById('filters').innerHTML = Object.entries(CATEGORIES)
.map(([key, c]) => `
<label>
<input type="checkbox" value="${key}" checked>
<span class="sw" style="background:${c.color}"></span>
<span>${c.label}</span>
<span class="count">${counts[key] || 0}</span>
</label>`)
.join('');
document.getElementById('filters').addEventListener('change', (e) => {
const key = e.target.value;
e.target.checked ? active.add(key) : active.delete(key);
applyFilter();
});
}
map.on('load', () => {
map.addSource('places', { type: 'geojson', data: DATA });
const colorByCategory = ['match', ['get', 'category'],
...Object.entries(CATEGORIES).flatMap(([key, c]) => [key, c.color]),
'#999999'
];
map.addLayer({
id: 'points',
type: 'circle',
source: 'places',
paint: {
'circle-radius': 7,
'circle-color': colorByCategory,
'circle-stroke-color': '#fff',
'circle-stroke-width': 2
}
});
map.addLayer({
id: 'points-label',
type: 'symbol',
source: 'places',
layout: {
'text-field': ['get', 'name'],
'text-font': ['Roboto Regular'],
'text-size': 11,
'text-offset': [0, 1.2],
'text-anchor': 'top'
},
paint: { 'text-color': '#333', 'text-halo-color': '#fff', 'text-halo-width': 1.2 }
});
buildControls();
});
</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
setFilter changes what a layer draws without touching the source. The data is uploaded
once and stays put, so toggling a category is a style change rather than a re-upload, which
is what keeps this fast when the source is large.
The filter is ['in', ['get', 'category'], ['literal', [...active]]]. The ['literal', …]
wrapper is required. An array written directly into an expression is read as another
expression, so passing the bare list produces an error about an unknown operator rather than
a filter. This is the single most common mistake with in.
active.size === 0 is handled separately. ['in', x, ['literal', []]] does evaluate to
false for everything and would work, but spelling out the empty case makes the intent
readable and avoids relying on that behaviour.
Both layers get the same filter. Points and their labels are separate layers over one source, so filtering only the circles leaves orphaned labels floating over an empty map. Anything that draws from the same source needs the same filter applied, which is an argument for keeping the layer ids in one list once there are more than two.
The colour expression is generated from the same CATEGORIES object that builds the
checkboxes, so a new category means one entry rather than four edits.
Counts come from the data rather than from what is visible, so they stay stable as boxes are
ticked. Counting the filtered result instead is also reasonable, but it needs
queryRenderedFeatures and only ever counts what is on screen.
Next steps
A category filter composes with the other kinds. Combining it with
a time slider means an all expression wrapping
both conditions, which is how most real filter interfaces end up built.
For floor plans the same mechanism is a level switcher, filtering extruded rooms to one storey at a time. Once the data outgrows a GeoJSON download, Connectors serve the same properties as vector tiles and the filter expression is unchanged.