Open-source indoor venue map built using OpenLayers and QGIS. Based on the Field Museum's digital map.
As of May 2022, this project should be considered abandoned. It's left here for archival purposes, but I would strongly recommend that you look elsewhere for your indoor mapping needs.
There are many vendor-supported commercial solutions available now, along with other open-source solutions: https://wiki.openstreetmap.org/wiki/Indoor_Mapping#Tools
Unfortunately, I no longer work for my previous employer, and even when I was there, resources were diverted away from this project soon after its release. No further development or maintenance will occur. You are still welcome to use this as an example and ask questions if you need help, but if you're looking for something production-ready, look elsewhere instead. Good luck!
Example of an interactive, indoor museum map built by the Field Museum using OpenLayers 6.5 and QGIS 3.16. See the working example or the production version of the Field Museum Digital Map.
We are releasing this code as an example for other museums and venues to freely use, but unfortunately we will not be able to officially support it due to limited resources. You are welcome to file issues and pull requests, but we cannot guarantee a timely response.
The codebase is messy and poorly modularized/abstracted. Although we use this same code in our production digital map, we consider it an alpha release in need of cleanup and refactoring by more experienced devs. Nonetheless, it works as intended and is relatively well documented.
We hope this will be useful to someone out there!
Portions Copyright 2020 Field Museum of Natural History. Based on work by the OpenLayers and QGIS projects.
We are releasing this source code under the 2-clause BSD license, same as OpenLayers itself.
Content in this demo map (geometries, icons, etc.) is also released under Creative Commons 4.0 Attribution (CC-BY-4). Of course, you'd probably want to replace our example content with your own.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
We'd like to thank the following people for their contributions in time and code to this project. This wouldn't have been possible without you!
We hope our map is intuitively usable by visitors already familiar with Google Maps or Apple Maps, though we don't have the data to prove this. In time, we hope to add more real user monitoring and in-app analytics.
Basic interactions are provided by OpenLayers by default and used like any other web map (panning, zooming, etc.)
In addition, visitors can:
These are currently undocumented in the UI because most of our visitors are on mobile anyway. We just added these for our own convenience.
npm start
. This will use Parcel to build a dev environment and start a dev server at localhost:1234
. This should also enable autoreload, so editing code or CSS (anything that Parcel sees) should cause the page to automatically refreshnpm build
. The built output should be in /docs
. We use docs
for Github Pages compatibility; you can reconfigure the output directory by editing /package.json
.index.js - The main app, containing all the custom code we built
index.css - Styling for the UI (buttons, logo, etc.)
assets/ - Source files for our assets
cms/ - Content (colors, descriptions, links, etc.) for our points of interest. A JSON dump from Contentful.
icons/ - SVG icons used for amenities and pictograms
images/ - UI images used in index.html
layers/ - Floor source files (tif, png), outputs (geoJSON), and QGIS project
docs/ - The built and minified production files from 'npm build'. This is what you serve to the public on a real webhost.
.idea/ - Ignore this. It's project-specific configuration used by our IDE, PHPStorm.
/assets/layers/qgis-project.qgz
as an example so you can see how this was done, and there are numerous tutorials online and on YouTube. At some point we hope to record a video tutorial of our own showing how we did this; if that would be helpful, please let us know./assets/layers/qgis-project.qgz
for an example built in QGIS.properties{}
, which will NOT work)LayerFiles{}
, configure them in LayerSettings{}
, style them by layer type in LayerStyles{}
, group them into floors in Floors{}
tfmMap
and view with tfmView
. The map is the parent OpenLayers object that controls and draws everything. The view controls the human-visible viewport, along with zoom and rotation constraints.ol-layerswitcher
to handle switching between floors (layer groups)tfmMap.on('click')
) and plain Javascript logic to handle clicks on different types of features, e.g. an area itself or the label associated (by ID) to that area.Apologies in advance: This project was built in a hurry, with only one developer, and we did not create the codebase in a very modular manner. Configuration parameters are spread all throughout index.js
, though the majority are near the top. We hope to clean this up and refactor it into proper modules and files at a later point. In the meantime, here's the most important configurations to keep track of in index.js
:
contentfulData{}
object. We chose to use Contentful for this project, but you can replace it with any other CMS you like (Drupal, Wordpress with Advanced Custom Forms, Airtable, Google Sheets... anything that can give you a JSON with IDs to work with). Or if you want to skip a CMS and hardcode it all into a JSON file, that's what we did with fallback-data.json
(which is our local cache of Contentful's API output; we use this to ensure the map loads even if Contentful is unreachable, albeit that will result in slightly stagnant data).require
geoJSON files into the LayerFiles{}
object, then loop through that to populate the actual LayerSources{}
object using the OpenLayers API and a simple forEach loop. LayerSources{}
. These sources are configured in LayerSettings{}
and styled in LayerStyles{}
. Phew! Messy, ain't it?Floors{}
object, which brings together all the layers, settings, and styles for each floor.tfmXXXXX{}
constants like tfmZooms{}
or tfmIcons{}
: These are really just for our convenience. Most are self-explanatory. Of particular note is tfmIcons{}
, which loads in a bunch of SVGs using Parcel's require
. This is a quirk of our particular toolchain; if you were using webpack or something else, you might be able to load in these SVGs another way (e.g. with import
).layerSwitcher
options: We use ol-layerwitcher to switch between floors (layer groups). Currently, users can deselect all the floors by clicking the same floor twice; we hope to address this with this eventual patch.closed
status: We use this in several layer types in order to indicate whether an exhibition is open to the public as normal (closed='Open' or 0
), temporarily closed due to COVID (closed='Closed' or 1
), or restricted to staff and never open to the public (closed='Staff-Only' or 2
). We realize this numbering scheme is inconsistent, and that's something we hope to clean up in the future. You might see some orphaned styling code (commented out) where the temporarily closed areas can be displayed as diagonal gray lines instead of solid gray fill; we opted against that in the released version to maintain visual simplicity.tfmMap.on('click', e=>{})
) near line 727 does the rest of the magic. The basic logic is:
layerFilter
parameter of the click handler. In our case only layers marked as tfmClickable=true
in their LayerSettings{}
template will be considered clickable. In our case, that's labels (text and padding), pictograms (their SVG fills), and areas (geoJSON polygons). OpenLayers is pretty good at evaluating where the click actually happened, though it does struggle a bit with more complex, layered SVG files (which only matters for the pictograms). Our setup is a bit more complex than yours might be, because our branded print map design forced our hand in doing something similar for our exhibitions, giving them labels in addition to their polygonal areas. These labels are defined in another point layer (e.g. upper_area_labels.geojson) and formatted using OpenLayers. They are intended to be the main clickable things on the map, though users can also choose to click on an area if they so choose.forEachFeatureAtPixel()
. The reason we use that instead of OL's selection
handler is due to an apparent bug that sometimes catches label clicks at the edges of padded text (our labels) to register right. We never got to the bottom of that, so we use forEachFeatureAtPixel()
instead, but just the first hitswitch(tfmLayerType)
statement examines the kind of layer it is (again set in LayerSettings{}
) and proceeds accordingly. Ultimately, exhibitions are areas, and labels witch matching IDs go to those areas. It's important to make sure your IDs are unique and consistent between the .geoJSONs for the area and label layers, and also in your CMS.
Documentation for these will come later. For the most part, they are self-explanatory or explained in the code comments... hopefully. If anything is confusing, please reach out.
declutter=true
can hide some when they overlap, but that's not desirable behavior either. OpenLayers doesn't seem to have a "rearrange labels so they don't overlap" feature the way ArcMap or QGIS dool-layer-switcher
(the package we use for the floor switcher buttons) doesn't allow mutually exclusive layer groups (i.e. floors consisting of multiple layers) to be toggled as radio buttons. They are instead checkboxes styled to look like buttons, but clicking on one that's already selected will "uncheck" it and cause all the layer to disappear, resulting in no floor being visible.for file in *.geojson; do jq '(.features[] | select(.properties.id != null)) |= (.id = .properties.id)' $file > "$file"_tmp; mv "$file"_tmp $file;done
. This is because QGIS saves feature IDs as properties (see https://github.com/qgis/QGIS/issues/40876).id
member and the Contentful entry id, which is also the last part of an entry's URL. However, you can only specific this ID via the Contentful Content Management API, NOT via the Contentful website. If you try to add a new entry on the website, it will get a random UUID as its entry ID and OpenLayers won't be able to find it. In hindsight we should've used a separate field in Contentful to match entries.scale
property causes aliasing. It appears OpenLayers is rendering to canvas first and then resizing, rather than using a high-res vector to scale. To work around this, we often start with a bigger-than-intended SVG declared size and then scale down from there.switchFloors()
. ol-layer-switcher
doesn't have any hooks/events we can tie into.