Tutorialmapbox-glindoor mapsfill-extrusion

How to build an indoor map with Mapbox GL JS

Build a Mapbox GL JS indoor map: extrude rooms and walls, switch floors with filters, add live data, know the indoor API status and avoid occlusion pitfalls.

8 min readPublished By Spatial Map team
Retail Mart Store demo in Spatial Map, built on Mapbox GL JS: three floors in 3D with department areas, racks and escalators

Building a Mapbox GL JS indoor map

A Mapbox GL JS indoor map is mostly made of three things the library already does well: GeoJSON sources, `fill-extrusion` layers for walls and rooms, and filters that decide which floor is visible. This guide walks through each with short code samples, then covers the pitfalls that take the most time — floors, live data and markers that draw through walls.

Spatial Map is an indoor mapping platform built on Mapbox GL JS, and its whole app is available as MIT-licensed open source. The patterns below come from building it; the last section covers when to build your own and when to start from that code instead.

Start with the data model

Everything is easier if every feature carries the same few properties: a stable `id`, a `kind` (room, wall, door, rack, stair), a `level`, a `name` and a `height`. Coordinates are longitude and latitude, so the building has to be georeferenced first — usually by tracing over a floor plan placed on the map.

json·One room as a GeoJSON feature
{
  "type": "Feature",
  "properties": {
    "id": "L2-214",
    "kind": "room",
    "level": 2,
    "name": "2.14 Meeting",
    "height": 0.15
  },
  "geometry": {
    "type": "Polygon",
    "coordinates": [[[-0.12801, 51.50702], [-0.12786, 51.50702],
                     [-0.12786, 51.50711], [-0.12801, 51.50711],
                     [-0.12801, 51.50702]]]
  }
}

Walls need to be polygons, not lines: `fill-extrusion` only extrudes areas. Buffer each wall centre-line by half its thickness (Turf’s `buffer` works, though it rounds the ends), or offset both sides yourself for square corners. Doors are easiest as gaps in the wall polygons plus a separate feature for the door leaf.

Extrude rooms and walls

Two layers on one source give you a recognisable 3D building. Floor slabs (rooms) get a thin height so they read as surfaces; walls get their real height. Expressions do the stacking:

ts·fill-extrusion layers for rooms and walls
const STOREY = 4; // metres from one floor level to the next

map.on('load', () => {
  map.addSource('indoor', {
    type: 'geojson',
    data: '/data/building.geojson',
    promoteId: 'id', // lets feature-state address rooms by their own ID
  });

  // Each floor is lifted by level * STOREY, so the building stacks in 3D.
  const base = ['*', ['get', 'level'], STOREY];

  map.addLayer({
    id: 'rooms',
    type: 'fill-extrusion',
    source: 'indoor',
    filter: ['==', ['get', 'kind'], 'room'],
    paint: {
      'fill-extrusion-color': ['coalesce', ['feature-state', 'color'], '#d4d4d8'],
      'fill-extrusion-base': base,
      'fill-extrusion-height': ['+', base, ['get', 'height']],
    },
  });

  map.addLayer({
    id: 'walls',
    type: 'fill-extrusion',
    source: 'indoor',
    filter: ['==', ['get', 'kind'], 'wall'], // walls are thin polygons, not lines
    paint: {
      'fill-extrusion-color': '#a1a1aa',
      'fill-extrusion-base': base,
      'fill-extrusion-height': ['+', base, ['get', 'height']],
    },
  });
});

Floors as filters

Most indoor maps need two views: all floors stacked (for an overview in 3D) and one floor at a time (for reading it). The second is a filter per layer plus a small floor switcher in your UI:

ts·Show one level
const LAYER_KIND: Record<string, string> = { rooms: 'room', walls: 'wall' };

function showLevel(level: number) {
  for (const [layerId, kind] of Object.entries(LAYER_KIND)) {
    map.setFilter(layerId, [
      'all',
      ['==', ['get', 'kind'], kind],
      ['==', ['get', 'level'], level],
    ]);
  }
}

showLevel(2);

If you want the other floors faded rather than hidden, note that `fill-extrusion-opacity` is one value for the whole layer — it cannot vary by feature. Fading “every floor except this one” therefore means one layer per floor (or per opacity step), each with its own filter and opacity.

What about Mapbox’s own indoor support?

Mapbox documents indoor mapping in its Standard style, including an example that adds your own indoor data. At the time of writing, docs.mapbox.com labels it clearly: “Indoor mapping is an experimental feature. APIs may change in future releases.” The example uses an inline style import whose indoor metadata source defines the floor hierarchy, layers filtered with an `is-active-floor` expression, and an `IndoorControl` that draws a floor selector for you.

ApproachGood forTrade-off
Your own sources and `fill-extrusion` layersProduction apps, any style, full control of floors and dataYou write the floor switcher and stacking yourself
Experimental indoor in the Standard stylePrototypes that want a built-in floor selectorAPIs may change; tied to the Standard style; check the docs before relying on it

Put live data on it

Live data comes in two shapes. States of fixed things (a room’s temperature, a bin’s stock) are best as feature-state on the shapes you already have. Moving things (tags, vehicles, people counts at points) belong in their own source that you replace on a timer or a socket message.

ts·Feature-state for rooms, a separate source for moving points
// Recolour a room from live data: no geometry is re-sent.
map.setFeatureState({ source: 'indoor', id: 'L2-214' }, { color: '#ef4444' });

// Moving things (tags, vehicles) live in their own source that you replace.
map.addSource('tags', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
  id: 'tags',
  type: 'circle',
  source: 'tags',
  paint: { 'circle-radius': 5, 'circle-color': '#2563eb' },
});

setInterval(async () => {
  const res = await fetch('/api/positions'); // your RTLS or middleware
  (map.getSource('tags') as mapboxgl.GeoJSONSource).setData(await res.json());
}, 5000);

Two details bite here. Feature-state needs feature IDs (`promoteId` or numeric `id`s), and replacing a GeoJSON source’s data with `setData` clears the state set on it — so keep states on the static source and movement on its own. And a `circle` layer always sits on the ground: fine for one floor, wrong for a tag on level 3 of a stacked building.

Pitfalls worth knowing in advance

  • Markers draw through walls. `mapboxgl.Marker` is a DOM element above the WebGL canvas, so it never takes part in the depth test; its `occludedOpacity` only reacts to terrain. Our fix is in marker occlusion behind 3D buildings.
  • Basemap buildings overlap yours. The basemap’s own 3D buildings draw through your interior. In classic styles, hide or filter the building extrusion layer; in Standard, use its config (for example `show3dObjects`) or the indoor footprint clipping described in the docs.
  • Coplanar surfaces flicker. A label, a slab and a floor-plan image at exactly the same height z-fight. Give slabs a few centimetres of thickness and lift what sits on them.
  • Floor-plan images are corner-based. An `image` source takes four corner coordinates, so rotating a plan means recomputing the corners. Keep images at 4096 px or less on the long edge; larger textures fail on some devices.
  • Clicks hit every floor. `queryRenderedFeatures` returns whatever is drawn under the cursor, including shapes on floors you have faded. Filter the result by the active level before you open a details panel.
  • Labels pile up. Room names on stacked floors collide in a tilted view. Give each floor its own symbol layer and show labels only for the active one, or only at higher zoom.
  • Big buildings, big sources. One GeoJSON source per building is fine for most sites. For very large campuses, split sources per floor or move static geometry to vector tiles, and keep the frequently changing data small.
  • Metres are not Mercator units. If you build geometry in local metres, convert it at the building’s own latitude (`MercatorCoordinate.fromLngLat(...).meterInMercatorCoordinateUnits()` gives the factor), or sizes come out wrong away from the equator.
Three floors stacked, with markers that respect walls and slabs
Retail Mart Store demo in Spatial Map at an oblique pitch: three floors with department areas, racks, escalators and RFID reader markers at their own floor’s height
Retail Mart Store demo in Spatial Map at an oblique pitch: three floors with department areas, racks, escalators and RFID reader markers at their own floor’s height

Build your own, or start from Spatial Map?

A viewer for one building with a floor switcher and a few live layers is a reasonable project on raw Mapbox GL JS. The cost grows with what comes next: an editor so non-developers can change the map, PDF and DWG import, racks with bin addresses, doors that cut walls, multi-floor routing, occlusion, permissions, share links and reports.

Build your own when…Start from Spatial Map when…
The map is a small feature inside a larger productThe map is the product, or the main screen of an internal tool
Your data model is unusual and fixedYou need an editor, floors, racks, doors and routing as they are
You will not need non-developers to edit itOperations or facilities teams will maintain the map
You want no dependencies beyond the map libraryYou want a working base under MIT you can fork and change

The open-source edition is the full Spatial Map app — React and TypeScript on Mapbox GL JS, a Go API and PostgreSQL with PostGIS — started with Docker Compose and a free Mapbox token. Its ARCHITECTURE.md and docs folder describe how floors, overlays and occlusion are wired. If you would rather not run it at all, the hosted 3D indoor map is the same app.

Try the platform your data belongs on.

Spatial Map turns spreadsheets, sensor streams, and CAD drawings into one live 3D facility view — nothing to install, seven-day free trial.

Keep reading