<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nick Lebedev</title>
    <description>The latest articles on DEV Community by Nick Lebedev (@nextstopsun).</description>
    <link>https://dev.to/nextstopsun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4097585%2Ff5cbede8-06a3-4e0b-9fdf-5f0f39d8ff0f.jpg</url>
      <title>DEV Community: Nick Lebedev</title>
      <link>https://dev.to/nextstopsun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nextstopsun"/>
    <language>en</language>
    <item>
      <title>Build an indoor map with your own data in Mapbox GL JS</title>
      <dc:creator>Nick Lebedev</dc:creator>
      <pubDate>Fri, 28 Aug 2026 11:33:53 +0000</pubDate>
      <link>https://dev.to/mapbox/build-an-indoor-map-with-your-own-data-in-mapbox-gl-js-127m</link>
      <guid>https://dev.to/mapbox/build-an-indoor-map-with-your-own-data-in-mapbox-gl-js-127m</guid>
      <description>&lt;p&gt;Mapbox recently added indoor data to its public map styles. Zoom into one of 200+ mapped airports in the Mapbox Standard style, flip on a single configuration flag, and you get floor plans, a floor selector, and a smooth transition from the street into the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;mapboxgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mapbox://styles/mapbox/standard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;basemap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;showIndoor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;mapboxgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IndoorControl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole integration. Coverage is expanding, and more venue types are coming.&lt;/p&gt;

&lt;p&gt;But there's a decent chance the building you actually care about - your office, a campus, a hospital, a stadium, a transit hub - isn't in that dataset and won't be for a while. So can you run the same machinery on your own floorplans?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Yes&lt;/strong&gt; - and you don't even need to publish a tileset to do it. The indoor system in GL JS is not hard-wired to Mapbox's tilesets, or to any tilesets at all. Which data drives it is a configuration in the &lt;em&gt;style&lt;/em&gt;, and that config is happy to point at your custom data. Change the config and the whole apparatus - building detection, floor state, the floor switcher UI, per-floor filtering - runs on your data instead.&lt;/p&gt;

&lt;p&gt;This post assumes you already have the two things that actually take effort to produce: a building footprint and a set of floorplan shapes (rooms, corridors, whatever you've got) for each floor. From there it's a straight line: derive the metadata that makes floor switching work, drop it next to your other GeoJSON files, and wire up the style.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; indoor support in SDKs is marked experimental, and the style property this post relies on isn't in the public style specification yet. It works today (this was written against GL JS v3.28.1), but treat it as something that can change between minor versions, and pin your SDK version if you ship it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The part that isn't obvious
&lt;/h2&gt;

&lt;p&gt;Before touching data, it's worth understanding how Mapbox SDK decides what "the current floor" means, because every design decision downstream follows from it.&lt;/p&gt;

&lt;p&gt;There are two kinds of layers in an indoor tileset, and they do completely different jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata layers&lt;/strong&gt; describe buildings and floors. Their geometries are never rendered - they're just coverages that are scanned to build a dependency graph: which buildings exist, where their centers are, which floors belong to which building, which floors can be shown together, and which one gets shown by default. This graph populates the floor selector and tracks the active floor as the user pans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geometry layers&lt;/strong&gt; are the things you actually see on the map - structures, floors, floorplans, doors, labels etc. They get filtered by an expression that asks "is this feature's floor currently active?":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-active-floor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;floor_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Metadata layers are the state machine, geometry layers are the view.&lt;/strong&gt; Once that clicks, the rest of the work is mostly bookkeeping - making sure the identifiers in your view layers line up with the graph in your state layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://codepen.io/editor/nicklebedev/pen/01a0381b-a833-7c3f-b2e1-81fefb33e34d" rel="noopener noreferrer"&gt;sample map&lt;/a&gt; of a single three-storey building with a working floor switcher, rendered on top of Mapbox Standard, reading straight from our own indoor data. Everything scales from there: more floors, more buildings - up to a whole campus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define the schema
&lt;/h2&gt;

&lt;p&gt;Mapbox's indoor tileset is a useful template for property names even though we're not publishing a tileset here - it's exactly what GL JS's parser expects either way. The &lt;a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-indoor-v3/" rel="noopener noreferrer"&gt;Indoor v3 tileset reference&lt;/a&gt; documents the full schema; you don't need all of it, we’ll start with just a few files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Geometry&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;indoor-structure.geojson&lt;/td&gt;
&lt;td&gt;Polygon&lt;/td&gt;
&lt;td&gt;Building footprints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;indoor-floorplan.geojson&lt;/td&gt;
&lt;td&gt;Polygon&lt;/td&gt;
&lt;td&gt;Rooms, amenities, corridors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;indoor-door.geojson&lt;/td&gt;
&lt;td&gt;Line&lt;/td&gt;
&lt;td&gt;Doors and openings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;indoor-pois.geojson&lt;/td&gt;
&lt;td&gt;Point&lt;/td&gt;
&lt;td&gt;Points of interest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;indoor-metadata.geojson&lt;/td&gt;
&lt;td&gt;Polygon&lt;/td&gt;
&lt;td&gt;Building and floor graph together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hierarchy is &lt;strong&gt;structure → floor → floorplan/door/poi&lt;/strong&gt;, and each level points at its parent by ID: floor metadata features carry &lt;code&gt;structure_ids&lt;/code&gt;, everything on a floor carries &lt;code&gt;floor_id&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The properties that are actually mandatory
&lt;/h3&gt;

&lt;p&gt;Most of the schema is optional and only affects styling. These are the ones GL JS will reject a feature over.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;structure metadata feature&lt;/strong&gt; needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"structure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bldg1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sample building"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"center_lon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;22.56002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"center_lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;62.136234&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;type&lt;/code&gt; must be the literal string &lt;code&gt;"structure"&lt;/code&gt;. &lt;code&gt;center_lon&lt;/code&gt;/&lt;code&gt;center_lat&lt;/code&gt; should longitude and latitude of a point within the building - GL JS uses them to find the closest building to the viewport.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;floor metadata feature&lt;/strong&gt; needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"floor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fl1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"z_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structure_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bldg1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conflicted_floor_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fl2;fl3"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;type&lt;/code&gt; must be &lt;code&gt;"floor"&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;z_index&lt;/code&gt; must all be present. &lt;code&gt;name&lt;/code&gt; is what appears on the floor selector button, so keep it short - "1", "2A", "-1". &lt;code&gt;z_index&lt;/code&gt; sorts the floors vertically.&lt;/p&gt;

&lt;p&gt;The three &lt;code&gt;_ids&lt;/code&gt; fields are semicolon-delimited lists, and they're where most of the interesting behaviour lives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;structure_ids&lt;/strong&gt; links a floor to its building. Omit it and the floor is orphaned - it exists, but no building claims it, so it never appears in any selector.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;is_default&lt;/strong&gt; marks the floor shown when the venue first appears on the map. Set it on exactly one floor per building, usually the lowest non-negative z_index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;conflicted_floor_ids&lt;/strong&gt; lists floors that must never be visible at the same time.&lt;/li&gt;
&lt;li&gt;And optionally &lt;strong&gt;connected_floor_ids&lt;/strong&gt; lists floors that &lt;em&gt;should&lt;/em&gt; be shown alongside this one - useful when a walkway on level 2 of one building connects to level 3 of another.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;conflicted_floor_ids&lt;/strong&gt; is the one people skip, and skipping it produces a genuinely confusing bug. A floor counts as active if it's the one the user just selected, or the default floor, or the previously active floor - and stays active for all of those unless &lt;code&gt;conflicted_floor_ids&lt;/code&gt; says two of them can't coexist. So within a building, every floor should list every one of its siblings as a conflict.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 2: Shape your GeoJSON files
&lt;/h2&gt;

&lt;p&gt;Let’s say we start with a building footprint in &lt;code&gt;indoor_structure.geojson&lt;/code&gt; and our rooms referenced to a floor in &lt;code&gt;indoor_floorplan.geojson&lt;/code&gt;. Data can originate from whatever mapped the venue - CAD, a survey, or tracing a raster. For small venues &lt;a href="http://geojson.io" rel="noopener noreferrer"&gt;geojson.io&lt;/a&gt; might be the quickest way to draw and edit properties manually.&lt;/p&gt;

&lt;p&gt;The important step is building metadata and there's a shortcut that makes it nearly free: &lt;strong&gt;the metadata geometries don't need to be precise per-floor outlines - they're coverage hints, not something that gets rendered.&lt;/strong&gt; So for a straightforward building, you can generate every metadata feature by duplicating the building footprint and for complex you can use bounding boxes. &lt;br&gt;
For example if you work in geojson.io:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Duplicate the building footprint feature once and add the structure properties (&lt;code&gt;type: "structure"&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;center_lon&lt;/code&gt;, &lt;code&gt;center_lat&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;Duplicate it again for each floor, adding that floor's properties (&lt;code&gt;type: "floor"&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;z_index&lt;/code&gt;, &lt;code&gt;structure_ids&lt;/code&gt;, &lt;code&gt;is_default&lt;/code&gt;, &lt;code&gt;conflicted_floor_ids&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;Put all of these features - the one structure feature and every floor feature - into a single GeoJSON FeatureCollection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7osqgzj4p2vnnqppmfjv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7osqgzj4p2vnnqppmfjv.png" alt="Create indoor metadata in geojson.io" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In our case for a single building that's one structure feature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;center_lon&lt;/th&gt;
&lt;th&gt;center_lat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bldg1&lt;/td&gt;
&lt;td&gt;Sample building&lt;/td&gt;
&lt;td&gt;structure&lt;/td&gt;
&lt;td&gt;22.56002&lt;/td&gt;
&lt;td&gt;62.136234&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;...and one floor feature per storey, all sharing the building's footprint geometry:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;z_index&lt;/th&gt;
&lt;th&gt;structure_ids&lt;/th&gt;
&lt;th&gt;is_default&lt;/th&gt;
&lt;th&gt;conflicted_floor_ids&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fl1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;floor&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;bldg1&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;fl2;fl3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fl2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;floor&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;bldg1&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;fl1;fl3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fl3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;floor&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;bldg1&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;fl1;fl2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the entire floor graph all in just one file. See the &lt;code&gt;indoor_metadata&lt;/code&gt; object in &lt;a href="https://codepen.io/editor/nicklebedev/pen/01a0381b-a833-7c3f-b2e1-81fefb33e34d" rel="noopener noreferrer"&gt;the codepen&lt;/a&gt; for the complete sample.&lt;/p&gt;

&lt;p&gt;If your floors genuinely have different footprints from the building outline (a stepped tower, a floor that's smaller than the ones below it), use the real per-floor outline instead of the duplicated building shape - the mandatory properties don't change either way.&lt;/p&gt;

&lt;p&gt;The layers that actually render need real geometry and reference their floor: it's quite easy to add data for rooms and corridors, door lines and point labels in &lt;a href="http://geojson.io" rel="noopener noreferrer"&gt;geojson.io&lt;/a&gt;. You can find sample data in the codepen constants.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: Write the style
&lt;/h2&gt;

&lt;p&gt;This is where it comes together. We’ll add a few sources that point at our files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor-metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor_metadata.geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor-structure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor_structure.geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor-floorplan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor_floorplan.geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor-doors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor_door.geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor-pois&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor_pois.geojson&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The piece that makes it all work is the &lt;code&gt;indoor&lt;/code&gt; config block. We point it at our metadata source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;indoor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;venue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indoor-metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;sourceLayers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;sourceLayers&lt;/code&gt; tells the engine there are no named source-layers to look for (there aren't any in a GeoJSON source) and to scan whatever single layer the source produces instead. If we were to use the tileset, we would need to list the layers that contain metadata features.&lt;/p&gt;

&lt;p&gt;That block goes inside a second, inline style import stacked on top of Mapbox Standard. Alongside it, style layers are filtered by &lt;code&gt;is-active-floor&lt;/code&gt; expression. The structure also gets a &lt;code&gt;clip&lt;/code&gt; layer to clear the path for displaying our data on the map with no overlapping features on top. Rooms doors and labels are just normal &lt;code&gt;fill&lt;/code&gt;/&lt;code&gt;line&lt;/code&gt;/&lt;code&gt;symbol&lt;/code&gt; layers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One easy-to-miss detail: &lt;strong&gt;indoor mode only turns on once some &lt;em&gt;visible&lt;/em&gt; layer reads from the metadata source.&lt;/strong&gt; The sample below adds a transparent fill layer (&lt;code&gt;fill-opacity: 0&lt;/code&gt;) on &lt;code&gt;indoor-metadata&lt;/code&gt; purely to trigger that - it's not meant to be seen, just present.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4: Turn on the floor selector
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;mapboxgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;22.560020&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;62.136234&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;19.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pitch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bearing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;mapboxgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IndoorControl&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;IndoorControl&lt;/code&gt; reads its state from the same graph your metadata features produced. Pan into the building and it populates with your floors, sorted by &lt;code&gt;z_index&lt;/code&gt;; click a floor and every layer filtered on &lt;code&gt;is-active-floor&lt;/code&gt; updates.&lt;/p&gt;

&lt;p&gt;Two notes:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IndoorControl&lt;/code&gt; requires &lt;strong&gt;GL JS v3.21.0 or newer&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
On iOS and Android (Maps SDK v11.19.0+), the equivalent is the &lt;code&gt;indoorSelector&lt;/code&gt; ornament/plugin - it ships a ready-made floor selector UI that appears automatically, same as &lt;code&gt;IndoorControl&lt;/code&gt;. You get the stock look out of the box and the custom floor-selector UI is on the roadmap.&lt;/p&gt;

&lt;p&gt;You do &lt;strong&gt;not&lt;/strong&gt; need &lt;code&gt;showIndoor: true&lt;/code&gt; for custom data - that flag turns on Mapbox's own indoor tileset, which is a separate thing. Leave it off unless you want both, and if your features happen to be somewhere Mapbox has already mapped, you should use ids of Mapbox metadata.&lt;/p&gt;

&lt;p&gt;The complete working example - all five sources, every layer, and the &lt;code&gt;IndoorControl&lt;/code&gt; setup - is in the attached codepen. Feel free to download, drop your own GeoJSON data and serve with any static server.&lt;/p&gt;

&lt;p&gt;&lt;iframe height="600" src="https://codepen.io/nicklebedev/embed/zxZYgBr?height=600&amp;amp;default-tab=result&amp;amp;embed-version=2"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Floor selector never appears&lt;/td&gt;
&lt;td&gt;Building isn't detected. Check &lt;code&gt;center_lon&lt;/code&gt;/&lt;code&gt;center_lat&lt;/code&gt; on the structure metadata feature (missing defaults to 0,0 - the Atlantic) and confirm &lt;code&gt;type&lt;/code&gt; is exactly &lt;code&gt;"structure"&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selector appears but is empty, or missing floors&lt;/td&gt;
&lt;td&gt;Floors are orphaned. Check &lt;code&gt;structure_ids&lt;/code&gt; matches the structure's &lt;code&gt;id&lt;/code&gt; exactly, that every floor has &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;z_index&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing happens at all, no floor UI, no errors&lt;/td&gt;
&lt;td&gt;No visible layer reads from the metadata source yet. Add a layer on it (even a transparent one) - the engine only activates indoor mode once one exists.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two floors render on top of each other when you switch&lt;/td&gt;
&lt;td&gt;Missing &lt;code&gt;conflicted_floor_ids&lt;/code&gt;. Previously-active and default floors stay visible unless they conflict with the new selection - every floor needs every sibling listed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two floors collapse into one button&lt;/td&gt;
&lt;td&gt;The selector de-duplicates by &lt;code&gt;z_index&lt;/code&gt; - gives each floor in a building a distinct value.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  At scale
&lt;/h2&gt;

&lt;p&gt;Everything above works because a handful of GeoJSON files stay small enough to parse client-side on every load. Once you're at dozens of floors, several buildings, or venues that update often enough that hand-maintaining files gets tedious, the same schema publishes just as well as a Mapbox vector tileset - and buys back the usual vector-tile benefits (server-side simplification, caching, not shipping full-precision geometry at every zoom etc).&lt;/p&gt;

&lt;p&gt;The workflow is the same conceptually - structure and floor metadata, plus visible features geometry - except a vector tileset supports multiple source-layers per source, so structure and floor metadata can reside in separate files if you'd rather generate them that way. Publishing may be performed with the &lt;a href="https://docs.mapbox.com/mapbox-tiling-service/guides/tilesets-cli/" rel="noopener noreferrer"&gt;Tilesets CLI&lt;/a&gt; (&lt;code&gt;upload-source&lt;/code&gt; per layer, a recipe, &lt;code&gt;create&lt;/code&gt; + &lt;code&gt;publish&lt;/code&gt;), and scripting can be used for metadata generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;The interesting thing here isn't any individual step - it's that the indoor system was built with the data source as a parameter rather than a constant. Mapbox's indoor tileset is the default, not a requirement, and it doesn't even need to be a &lt;em&gt;tileset&lt;/em&gt; - a few GeoJSON files are enough to get building detection, floor state management and a floor selector for free.&lt;/p&gt;

&lt;p&gt;This is still an experimental area. &lt;code&gt;IndoorControl&lt;/code&gt; is documented but flagged as subject to change, and the &lt;code&gt;indoor&lt;/code&gt; style property isn't in the published style specification yet. Pin your SDK version, and expect to revisit this when it graduates.&lt;/p&gt;

&lt;p&gt;If you build something with it, we'd like to see it - drop a comment or find us in the &lt;a href="https://discord.gg/sBBksyMjcd" rel="noopener noreferrer"&gt;Mapbox Developer Discord&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-indoor-v3/" rel="noopener noreferrer"&gt;Indoor v3 tileset reference&lt;/a&gt; - the full schema
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.mapbox.com/mapbox-gl-js/guides/indoor/" rel="noopener noreferrer"&gt;Indoor mapping in GL JS&lt;/a&gt; - the official guide for Mapbox's data
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.mapbox.com/map-styles/standard/api/" rel="noopener noreferrer"&gt;Mapbox Standard configuration reference&lt;/a&gt; - showIndoor, showIndoorLabels, indoor label featuresets&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.mapbox.com/mapbox-tiling-service/guides/tilesets-cli/" rel="noopener noreferrer"&gt;Tilesets CLI reference&lt;/a&gt; - if you want to utilize Mapbox vector tiles for your data&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://geojson.io" rel="noopener noreferrer"&gt;geojson.io&lt;/a&gt; - quick way to draw and property-edit data for a small venue &lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.mapbox.com/blog/build-indoor-airport-maps-with-the-new-mapbox-airport-indoor-maps-tileset" rel="noopener noreferrer"&gt;Mapbox airport indoor maps&lt;/a&gt; - Mapbox indoor data coverage&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mapbox</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>geospatial</category>
    </item>
  </channel>
</rss>
