Integrating Indoor Maps with MapLibre GL JS

This reference covers the web-renderer side of indoor delivery — turning a validated multi-floor GeoJSON envelope into an interactive MapLibre GL JS map with a working floor selector — and it sits inside the SDK Integration Patterns workflow, immediately after the artifact has been version-negotiated and handed to the browser.

What “Integrating an Indoor Map into MapLibre” Means

MapLibre GL JS is the open-source, no-token WebGL renderer forked from the last BSD-licensed Mapbox GL JS release. It draws vector data through the style specification: a JSON document of sources (where geometry comes from) and layers (how each geometry type is painted). Integrating an indoor map is not a matter of dropping an image onto a canvas — it is a matter of loading every floor’s geometry into one source and then painting only the active floor by filtering on a level property.

The load-bearing idea is that all floors coexist in a single source. An indoor building is a stack of overlapping polygons; a room on level 2 and a room on level 5 occupy the same longitude and latitude, so if you paint them all you get a smeared, unreadable overlay. MapLibre’s answer is setFilter: every render layer carries a filter expression, and the floor selector rewrites that expression to ["==", ["get", "level"], activeLevel]. Nothing is added or removed from the source; the GPU simply stops drawing features whose level does not match. The geometry itself is already trusted — it passed the contract in JSON Schema Design for Indoor Maps before it was ever served — so the client’s job is purely presentational: style, filter, fit, and track position.

For small buildings a single GeoJSON FeatureCollection per building is fine. For campuses with thousands of features per floor, you swap the source type from geojson to vector and point it at a tile endpoint built by Vector Tile Serving for Indoor Maps; the layer and filter code below is identical either way, which is the whole point of driving everything through the style spec.

Minimal Working Example: Map, Source, Floor Filter

The example below initialises a map with no access token, adds one GeoJSON source holding every floor, paints rooms as a fill layer, walls as a line layer, and POIs as a symbol layer, then filters all three to a single level. Every layer add is wrapped in map.on("load", ...) because a style layer cannot reference a source before the style has finished loading.

import maplibregl, { Map as MLMap, FilterSpecification } from "maplibre-gl";

const FLOOR_LAYERS = ["rooms-fill", "walls-line", "poi-symbol"] as const;

function levelFilter(level: number): FilterSpecification {
  return ["==", ["get", "level"], level];
}

export function initIndoorMap(container: string, envelopeUrl: string): MLMap {
  const map = new maplibregl.Map({
    container,
    style: { version: 8, sources: {}, layers: [], glyphs: "/glyphs/{fontstack}/{range}.pbf" },
    center: [0, 0],
    zoom: 18,
  });

  map.on("load", () => {
    map.addSource("indoor", { type: "geojson", data: envelopeUrl });        // all floors, one source
    map.addLayer({ id: "rooms-fill", type: "fill", source: "indoor",
      filter: levelFilter(1),
      paint: { "fill-color": "#dfeef2", "fill-outline-color": "#7fb2bd" } });
    map.addLayer({ id: "walls-line", type: "line", source: "indoor",
      filter: ["all", levelFilter(1), ["==", ["get", "space_class"], "wall"]],
      paint: { "line-color": "#0f3a4f", "line-width": 1.6 } });
    map.addLayer({ id: "poi-symbol", type: "symbol", source: "indoor",
      filter: ["all", levelFilter(1), ["has", "poi_name"]],
      layout: { "text-field": ["get", "poi_name"], "text-size": 11 } });
  });

  return map;
}

export function selectFloor(map: MLMap, level: number): void {
  for (const id of FLOOR_LAYERS) {
    const base = map.getFilter(id) as FilterSpecification;      // preserve any per-layer class filter
    const rest = Array.isArray(base) && base[0] === "all" ? base.slice(2) : [];
    map.setFilter(id, ["all", levelFilter(level), ...rest] as FilterSpecification);
  }
  const b = FLOOR_BOUNDS[level];
  if (b) map.fitBounds(b, { padding: 40, duration: 300 });      // frame the active floor extent
}

The floor selector is selectFloor: it rewrites the level clause on every layer and re-frames the viewport with fitBounds so switching floors also recentres on that floor’s footprint. FLOOR_BOUNDS is a lookup of [[minLng, minLat], [maxLng, maxLat]] per level, precomputed from each floor’s extent when the envelope is built.

MapLibre indoor rendering: one source, filtered layers, a live user marker A single GeoJSON source named indoor holds every floor's features. Three style layers read from that one source: a fill layer paints room polygons, a line layer paints walls, and a symbol layer places POI labels. Each layer holds a filter expression that compares the feature level property against the currently selected floor, so setFilter on a floor change re-paints only the active level without touching the source data. Independently, a second GeoJSON source named user holds a single point that is updated with setData on every PositionFix, driving the location marker so the blue dot stays in the same coordinate frame as the floor geometry. One source, three filtered layers, and a live user source source: "indoor" GeoJSON — all floors features carry level fill — rooms filter: level == active line — walls filter: level == active symbol — POIs filter: level == active floor selector setFilter(level) source: "user" single Point setData on each fix circle — user marker filter: level == active

Syncing the User Marker from a PositionFix

The blue dot is a second, tiny source — a GeoJSON point you overwrite with setData on every fix. Do not create a new marker per update; that churns the DOM and leaks WebGL buffers. Keep the point in the same coordinate frame as the floor geometry so it never drifts, and carry the fix’s level so the dot disappears when the user is on a floor you are not currently showing.

interface PositionFix { lng: number; lat: number; level: number; accuracy: number }

export function updateUserMarker(map: MLMap, fix: PositionFix): void {
  const src = map.getSource("user") as maplibregl.GeoJSONSource | undefined;
  const point = {
    type: "Feature" as const,
    geometry: { type: "Point" as const, coordinates: [fix.lng, fix.lat] },
    properties: { level: fix.level, accuracy: fix.accuracy },
  };
  if (src) src.setData(point);
  else {
    map.addSource("user", { type: "geojson", data: point });
    map.addLayer({ id: "user-dot", type: "circle", source: "user",
      filter: ["==", ["get", "level"], fix.level],
      paint: { "circle-radius": 7, "circle-color": "#c2557a", "circle-stroke-width": 2, "circle-stroke-color": "#fff" } });
  }
}

The PositionFix stream comes from the positioning layer that snaps raw estimates to the graph — see Positioning to Routing Graph — and it must already be expressed in the same longitude/latitude and level space as the rendered geometry.

Preparing the Envelope Extents Server-Side

Before the browser can fitBounds, someone has to compute each floor’s extent. This small server-side helper walks the validated envelope, groups features by level, and emits the per-level bounding boxes MapLibre needs, logging any floor whose geometry is degenerate.

import logging
from collections import defaultdict

import geopandas as gpd

logger = logging.getLogger(__name__)


def floor_bounds(envelope_path: str) -> dict[int, list[list[float]]]:
    """Return {level: [[minLng, minLat], [maxLng, maxLat]]} for a validated GeoJSON envelope."""
    gdf = gpd.read_file(envelope_path)
    if "level" not in gdf.columns:
        raise KeyError("envelope features must carry a 'level' property")
    out: dict[int, list[list[float]]] = defaultdict(list)
    for level, sub in gdf.groupby("level"):
        try:
            minx, miny, maxx, maxy = sub.total_bounds  # (minLng, minLat, maxLng, maxLat)
        except ValueError as exc:
            logger.warning("skipping level %s: degenerate extent (%s)", level, exc)
            continue
        out[int(level)] = [[float(minx), float(miny)], [float(maxx), float(maxy)]]
        logger.info("level %s bounds ok: %.6f,%.6f .. %.6f,%.6f", level, minx, miny, maxx, maxy)
    return dict(out)

Ship the result alongside the envelope so the client never has to scan every feature to frame a floor.

MapLibre Indoor Reference

Concept API / value Notes
Renderer new maplibregl.Map({ ... }) No accessToken; the style may reference any tile or glyph endpoint you host
Source (small building) { type: "geojson", data: url } One source holds every floor; features carry a level property
Source (campus scale) { type: "vector", url: "…/tiles.json" } Same layers/filters; tiles built by the vector-tile serving guide
Room layer type: "fill" Polygon fill + outline for space_class room/corridor
Wall layer type: "line" Line paint for wall geometry
POI layer type: "symbol" text-field: ["get", "poi_name"]; needs a glyphs URL in the style
Floor filter map.setFilter(id, ["==", ["get", "level"], n]) Rewrites the level clause; nothing is added or removed from the source
Frame a floor map.fitBounds(bounds, { padding }) Uses the per-level extent computed server-side
User marker getSource("user").setData(point) Overwrite one Point per fix; never re-create the source

Common Errors & Fixes

Every floor’s rooms show at once — the map is an unreadable overlay. The layers were added without a level filter, so MapLibre paints all floors stacked on the same coordinates. Add the filter at layer-creation time and rewrite it on every floor change:

map.setFilter("rooms-fill", ["==", ["get", "level"], activeLevel]);

Error: Source "indoor" not found / Style is not done loading. You called addLayer or addSource before the style finished initialising. Every source and layer mutation must happen inside the load event (or be guarded by map.isStyleLoaded()):

map.on("load", () => {
  map.addSource("indoor", { type: "geojson", data: envelopeUrl });
  // ...addLayer calls here, never before load fires
});

The user dot drifts away from the rooms. The PositionFix and the floor geometry are in different coordinate frames — one in raw metres, one in longitude/latitude. Reproject fixes into the same frame the envelope uses before calling setData; MapLibre never re-projects a source for you, it renders exactly the coordinates it is handed.

Integration Point

This renderer is the terminal consumer of the delivery path. It draws the validated FeatureCollection produced under JSON Schema Design for Indoor Maps, and at campus scale it reads the pre-cut tiles from Vector Tile Serving for Indoor Maps through an identical set of style layers. The live blue dot it paints is fed by the estimates snapped in Positioning to Routing Graph, which must arrive in the same longitude/latitude and level frame as the geometry. If you are shipping to native apps rather than the web, the token-and-licensing differences are covered in the companion Integrating Indoor Maps with Mapbox GL JS reference.

Frequently Asked Questions

Do I need one source per floor or one source for the whole building?

One source for the whole building. Every floor’s features live in a single GeoJSON (or vector-tile) source, each tagged with a level property, and you reveal a floor by rewriting each layer’s filter with setFilter. One source keeps the style small, lets a floor switch be a filter rewrite rather than a source add/remove, and means the user marker and every layer stay in one consistent coordinate frame. Splitting into a source per floor multiplies style objects and forces you to add and remove sources on every floor change for no benefit.

Should I use GeoJSON or vector tiles for the source?

Use inline GeoJSON while a building fits comfortably in memory — a few thousand features renders instantly and needs no tile server. Switch to a vector source pointed at a tile endpoint when a floor carries tens of thousands of features or you serve many buildings, because tiles are cut per zoom and only the visible extent is downloaded. The layer and filter code is identical for both; only the source type and its data/url change, so you can start with GeoJSON and migrate later without touching the floor selector.

Why does my map stay blank with no console error?

Almost always because the source and layers were added before the style loaded, so the calls silently no-op or throw a “style is not done loading” error that is easy to miss. Wrap every addSource/addLayer in map.on("load", ...). If the style loads but symbols never appear, confirm the style has a glyphs URL — MapLibre cannot render a text-field without a glyph endpoint to fetch the font stack from.

This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.