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.
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.
Related
- SDK Integration Patterns for Indoor Maps — the version-negotiation and degradation contract this renderer sits downstream of.
- Integrating Indoor Maps with Mapbox GL JS — the same source-and-layer model with a token, plus route rendering.
- Vector Tile Serving for Indoor Maps — the tile endpoint a
vectorsource reads at campus scale. - JSON Schema Design for Indoor Maps — the envelope contract the rendered features conform to.
- Positioning to Routing Graph — the source of the PositionFix stream that drives the user marker.
This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.