Skip to content

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.

Use the Maptoolkit Connector. Create an interactive map showing points of several categories from one GeoJSON source, with checkboxes that filter the layer by category using a filter expression.

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.