Example of an interactive, indoor museum map built by the Field Museum using OpenLayers 6.5 and QGIS 3.16. See the live demo used by our museum.
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! * Matthew Tarr and Raphael Pelegrino at the American Museum of Natural History for their wisdom and insights regarding indoor mapping * Jenn, Julia, and Lonny at Humboldt State University, who inspired a love of the natural world and the use of digital technology to help protect it * The teams behind OpenLayers and QGIS for making available the tools for amateurs and nonprofits to map their surroundings without breaking the bank * All the contributors to OpenStreetMap who revolutionized open-content, community-driven mapping * All the Field Museum staff who contributed to this project... thank 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: * Zoom in and out to see varying levels of detail (minor exhibitions, etc) * Click on specific exhibitions or labels to open a slide-in panel with a basic description, and a link out for more info * Use permalinks to access a specific floor and/or exhibition
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.
** Once a click is detected, we only want the topmost feature from 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) stat$ claude mcp add openlayers_indoor_map \
-- python -m otcore.mcp_server <graph>