openlayers_indoor_map/index.js

1145 lines
45 KiB
JavaScript

// Turn on debug mode during local development based on hostname
// Or when a query param "debug" is set (for CMS content previews)
const queryString = new URLSearchParams(window.location.search);
const hasDebugParam = queryString.has('debug');
if (window.location.hostname === 'localhost' || window.location.hostname === 'tfm-map-preview.vercel.app' || hasDebugParam) window.tfmDebug = true;
/*** BASIC SETUP ***/
// OpenLayer modules
import {Map, View} from 'ol';
import LayerSwitcher from 'ol-layerswitcher';
import GeoJSON from 'ol/format/GeoJSON';
import {Vector as VectorSource, OSM} from 'ol/source';
import {Tile as TileLayer, Vector as VectorLayer, Group as LayerGroup} from 'ol/layer';
import {Fill, Stroke, Style, Text, Icon, Circle, RegularShape} from 'ol/style';
import Point from 'ol/geom/Point';
import {defaults as defaultInteractions} from 'ol/interaction';
/*
Contentful SDK
Contentful is our CMS for most of the map metadata content (colors, descriptions, full names, etc.)
Currently, some non-clickable exhibition labels are hardcoded in the geoJSON layers directly
But as soon as something becomes interactive or needs editor configuration, it goes into Contentful
You can swap out Contentful for any other headless CMS of your choice. We tried this with Airtable, GSheets, etc.
To do so, you will have to either mirror Contentful's API structure or else refactor the data structures in this file
Sorry for our lack of abstraction!
*/
import "regenerator-runtime/runtime"; // Contentful needs it
import * as contentful from 'contentful'; // Basic JS SDK
import {documentToHtmlString} from '@contentful/rich-text-html-renderer'; // Renders Contentful's WYSIWYG rich text fields
// Stylesheets
import 'ol/ol.css'; // Some default OpenLayer styles
// index.css contains OUR styles. That's loaded via index.html instead (don't ask me why; just how OL set it up)
/*** PRELOAD CONTENT DATA ***/
// First we preload the data from a cached JSON file, for faster loading and just in case our CMS is down
import FallbackData from '/assets/cms/fallback-data.json';
let CMSData = {};
FallbackData.items.forEach(item => {
CMSData[item.sys.id] = item;
});
/*** REFRESH DATA FROM CMS ***/
// Disabled for the open-source release, but uncomment and it should work
/*
// Then we set up a fetch from our CMS, Contentful
// We default to the production build (i.e., entries marked PUBLISHED)
let client = contentful.createClient({
space: 'your_space_id', // The ID for your Contentful space
accessToken: 'your_api_token' // You'd want to set your own API key. SUE is ours!!
})
// But if debug mode is on, we fetch from the preview build instead
// This shows unpublished drafts so we can see how color changes, etc. will look without affecting visitors
if(window.tfmDebug) {
client = contentful.createClient({
host: 'preview.contentful.com', // To see unpublished changes
space: 'your_same_space_id', // The ID for your Contentful space
accessToken: 'your_PREVIEW_api_token' // Preview API key is different from regular key
})
}
client.getEntries({
content_type: 'poi',
}).then((response) => {
CMSData = {}; // Clear the fallback
// Replace it with Contentful's data
response.items.forEach(responseItem => {
CMSData[responseItem.sys.id] = responseItem;
});
}).catch(console.warn)
*/
/*** REUSABLE SETTINGS ***/
/*
We show/hide different things depending on zoom level
For example, important exhibitions and amenities (restrooms, etc.) are always visible,
while others only appear at `medium` or `close`.
Independently of zoom levels, some font and icon sizes scale as a function of the zoom level.
This is so that they can remain constant sized to the visitor, i.e. as zoom level increases, their size decreases
proportionally so that they remain the same on-screen size.
*/
const tfmZooms = {
far: 17,
medium: 18.5,
close: 20,
};
// Our brand colors. Our CMS uses these same string names in `fields.color`
const tfmColors = {
"Field Blue": '#0a46e6',
"Field Gray Lighter": '#F0F3F3',
"Field Gray Light": '#C9CACC',
"Field Gray": '#6a6a71',
"Field Gray Darker": '#333336',
"Field Black": '#0F0F14',
"Field Orange": '#F29F77',
"Field Purple": '#B274A7',
"Field Green": '#37816e',
"Success Green": '#53B59E',
"Warning Red": '#D44235',
"Map Dark Yellow": '#9a7e0b',
"Map Brown": '#663300',
"Map Light Blue": '#6FB4D6',
"Map Yellow": '#C6AD59',
"Map Magenta": '#A7197C',
"Map Light Green": '#AAC38A',
'Error Red': '#FF0000',
};
// Line widths
const tfmStrokes = {
narrow: 1, // Most lines (we use a minimalist style)
thick: 4, // COVID one-way flows
}
/*** LAYER SETUP ***/
// First read in the files
// Note that the `require()` function here is provided by our packer, Parcel, not OL or JS
// We do this because geoJSON files aren't ES6 modules and can't be properly `import()`ed.
// !!! SUPER IMPORTANT!!!
// Note that if you're editing these with QGIS, there's a bug that prevents saving geoJSON IDs properly
// QGIS saves IDs as inside `properties`, when the geoJSON spec says they should instead be a top-level member
// See https://github.com/qgis/QGIS/issues/40805 for details
// For now, the workaround is documented in that Github issue, or you can manually move IDs in the geoJSONs
// The IDs are used anywhere the OpenLayer `getId()` function is used, which is a lot of places.
const LayerFiles = {
ground: {
areas: require('/assets/layers/ground_level_areas.geojson'),
labels: require('/assets/layers/ground_level_labels.geojson'),
amenities: require('/assets/layers/ground_level_amenities.geojson'),
flows: require('/assets/layers/ground_level_flows.geojson'),
pictograms: require('/assets/layers/ground_level_pictograms.geojson'),
outline: require('/assets/layers/ground_level_outline.geojson'),
},
main: {
areas: require('/assets/layers/main_level_areas.geojson'),
labels: require('/assets/layers/main_level_labels.geojson'),
amenities: require('/assets/layers/main_level_amenities.geojson'),
flows: require('/assets/layers/main_level_flows.geojson'),
pictograms: require('/assets/layers/main_level_pictograms.geojson'),
outline: require('/assets/layers/main_level_outline.geojson'),
},
upper: {
areas: require('/assets/layers/upper_level_areas.geojson'),
labels: require('/assets/layers/upper_level_labels.geojson'),
amenities: require('/assets/layers/upper_level_amenities.geojson'),
flows: require('/assets/layers/upper_level_flows.geojson'),
pictograms: require('/assets/layers/upper_level_pictograms.geojson'),
outline: require('/assets/layers/upper_level_outline.geojson')
}
}
// Then load them into OpenLayer VectorSources
// In OpenLayers, each layer is a combination of both a geometry source (the VectorSource) and other data (styles, etc.)
const LayerSources = {};
Object.entries(LayerFiles).forEach(([floor, layers]) => {
LayerSources[floor] = {};
Object.entries(layers).forEach(([layerName, layer]) => {
LayerSources[floor][layerName] = new VectorSource({
url: layer,
format: new GeoJSON(),
})
})
});
// Defining reusable layer styles shared by all the layers of the same type (exhibition areas, amenity icons, etc.)
// They get used and assigned to particular layers in the next section, LayerSettings{}
const LayerStyles = {
// This is a hack to get around an issue with ol-layerswitcher: because our floor switcher "buttons" are actually
// checkboxes, it's possible to unselect ALL of them and be left with nothing visible on the map.
// In that case, this layer shows up and tells visitors to choose a floor.
WarningLayer: [
new Style({
fill: new Fill({
color: 'white',
}),
// https://openlayers.org/en/latest/apidoc/module-ol_style_Text-Text.html
text: new Text({
text: "Oops! You've unselected all the floors.\nPlease choose a floor to get back into the museum.\n\nPsst... did you know that TFM Members can go deep underground\n to see secret collections during Members' Nights?",
font: '10pt Graphik, sans-serif',
fill: new Fill({color: 'black'}),
overflow: false,
}),
}),
],
// When you zoom out far enough, we replace the museum internals with just our name and a message to zoom further in
ZoomedOutFootprint: [
new Style({
fill: new Fill({
color: tfmColors['Field Blue'],
}),
stroke: new Stroke({
color: tfmColors['Field Blue'],
width: 3,
}),
// https://openlayers.org/en/latest/apidoc/module-ol_style_Text-Text.html
text: new Text({
text: "FIELD MUSEUM ",
font: 'bold 16pt Druk, sans-serif',
fill: new Fill({color: 'white'}),
overflow: false,
}),
}),
new Style({
// https://openlayers.org/en/latest/apidoc/module-ol_style_Text-Text.html
text: new Text({
text: "(zoom in) ",
offsetY: 15,
font: '8pt Graphik, sans-serif',
fill: new Fill({color: 'white'}),
overflow: false,
}),
})
],
// Amenity icons
// `showAtFarZoom` is the list of amenity IDs (set in geoJSON) that will show up at the `far` zoom level
// Others show up at `medium` and in
// You can set zoom levels in the Reusable Settings section, near the top of this file
amenities: feature => {
const showAtFarZoom = ['elevator', 'stairs_up', 'stairs_up_down', 'stairs_down', 'restroom', 'restroom_male', 'restroom_female'];
const important = showAtFarZoom.includes(feature.get('type'));
if (!important && tfmView.getZoom() < tfmZooms.medium) return;
return new Style({
image: new Icon({
src: tfmIcons[feature.get('type')],
color: 'white',
scale: important ? (tfmView.getZoom() < tfmZooms.close ? 0.05 : 0.01 / tfmView.getResolution()) : 0.01 / tfmView.getResolution(),
opacity: important ? 1 : (0.1 / tfmView.getResolution()) + 0.25
}),
})
},
// Animal glyphs (dinosaurs, lions, and birds, oh my)
pictograms: feature => {
const id = feature.getId();
const coords = feature.getGeometry().getCoordinates();
const deskew = angle => (angle + 0.95) * Math.PI / 180; // To account for Chicago skewing off true north
const directions = {
north: deskew(0),
east: deskew(90),
south: deskew(180),
west: deskew(270),
};
switch (id) {
// North entrance
case 'north_entrance':
return new Style({
geometry: new Point(coords),
image: new RegularShape({
fill: new Fill({color: tfmColors["Field Gray"]}),
points: 3,
radius: 2 / tfmView.getResolution(),
angle: directions.north,
}),
text: new Text({
fill: new Fill({color: tfmColors["Field Gray"]}),
font: 1.5 / tfmView.getResolution() + 'pt Graphik, sans-serif',
text: 'Exit Only\n(Enter on ground Floor)',
offsetY: -5 / tfmView.getResolution(),
})
})
case 'south_entrance':
return new Style({
geometry: new Point(coords),
image: new RegularShape({
fill: new Fill({color: tfmColors["Field Gray"]}),
points: 3,
radius: 2 / tfmView.getResolution(),
angle: directions.south,
}),
text: new Text({
fill: new Fill({color: tfmColors["Field Gray"]}),
font: 1.5 / tfmView.getResolution() + 'pt Graphik, sans-serif',
text: 'Exit Only\n(Enter on ground Floor)',
offsetY: 5 / tfmView.getResolution(),
})
})
case 'east_entrance':
return new Style({
geometry: new Point(coords),
image: new RegularShape({
fill: new Fill({color: tfmColors["Field Blue"]}),
points: 3,
radius: 4 / tfmView.getResolution(),
angle: directions.west,
}),
text: new Text({
fill: new Fill({color: tfmColors["Field Blue"]}),
font: 'bold ' + 6 / tfmView.getResolution() + 'pt Graphik, sans-serif',
text: 'Entrance',
textAlign: 'right',
offsetX: -5 / tfmView.getResolution(),
})
})
default:
if (tfmView.getZoom() < tfmZooms.medium) return;
return new Style({
image: new Icon({
src: tfmPictograms[id],
color: 'white',
scale: tfmView.getZoom() < tfmZooms.close ? 0.1 / tfmView.getResolution() : 0.68,
opacity: (0.1 / tfmView.getResolution()) + 0.25
}),
})
}
},
// COVID one-way flow arrows
flows: feature => {
if (tfmView.getZoom() < tfmZooms.medium) return;
if (CMSData[feature.get('exhibition')].fields.closed === 'Closed' || CMSData[feature.get('exhibition')].fields.closed === 'Staff-Only') return;
const coords = feature.getGeometry().getCoordinates()[0]; // Returns an array of coordinate pairs
const endPoint = coords[coords.length - 1];
const startPoint = coords[0];
const secondToLastPoint = coords[coords.length - 2];
// Calculating a rotation angle from two coordinate pairs:
// https://gist.github.com/conorbuck/2606166
const rotationAngle = Math.atan2(endPoint[0] - secondToLastPoint[0], endPoint[1] - secondToLastPoint[1]);
let color;
try {
color = tfmColors[CMSData[feature.get('exhibition')].fields.color];
} catch (e) {
if (window.tfmDebug) console.warn(`Flow for exhibition ${feature.get('exhibition')} has no color, falling back to red.`, e, feature);
color = tfmColors['Error Red'];
}
return [
new Style({
stroke: new Stroke({
color: 'white',
width: 3 / tfmView.getResolution(),
lineJoin: 'miter',
})
}),
new Style({
stroke: new Stroke({
color: color,
width: 1.5 / tfmView.getResolution(),
lineJoin: 'miter',
})
}),
new Style({
geometry: new Point(startPoint),
image: new Circle({
fill: new Fill({color: color}),
radius: 2 / tfmView.getResolution(),
})
}),
new Style({
geometry: new Point(endPoint),
image: new RegularShape({
fill: new Fill({color: color}),
points: 3,
radius: 3 / tfmView.getResolution(),
angle: rotationAngle,
})
}),
];
},
// Major exhibition labels
labels: feature => {
const labelData = CMSData[feature.getId()] ? CMSData[feature.getId()].fields : undefined;
if (!labelData) return; // Don't show this label unless it's explicitly added to our CMS
if (!labelData.closed || labelData.closed === 'Closed' || labelData.closed === 'Staff-Only') return; // Don't show if closed or if the field was never set
if (labelData.showAtZoomLevel === 'medium' && tfmView.getZoom() < tfmZooms.medium) return; // Don't show if below set zoom level
const name = labelData.labelOverride ?? labelData.shortName;
let color = tfmColors[labelData.color] ?? tfmColors['Error Red']; // Fallback color
/*// Fade out unselected labels for easier ID when one is clicked on
// TOOD: Fix bugginess
if (selectedFeature && selectedFeature.getId() !== feature.getId()) color = tfmColors['Field Gray Light'];
*/
const fontSize = tfmView.getZoom() < tfmZooms.close ? 10 : 1.5 / tfmView.getResolution();
const labelWidth = tfmView.getZoom() < tfmZooms.close ? 20 : 3 / tfmView.getResolution();
const labelHeight = tfmView.getZoom() < tfmZooms.close ? 8 : 1 / tfmView.getResolution();
return new Style({
// Text parameters: https://openlayers.org/en/latest/apidoc/module-ol_style_Text-Text.html
text: new Text({
text: name + ' »',
font: 'bold ' + fontSize + 'pt Graphik, sans-serif',
textAlign: labelData.labelAlignment ?? 'center',
fill: new Fill({color: 'white'}),
backgroundFill: new Fill({color: color}),
padding: [labelHeight, labelWidth, labelHeight, labelWidth],
overflow: true, // Allow labels to exceed polygon width (or they'd just be hidden)
}),
})
},
Footprint: new Style({
fill: new Fill({
color: 'white',
}),
}),
outline: new Style({
stroke: new Stroke({
color: tfmColors["Field Gray"],
width: tfmStrokes.narrow,
}),
}),
// Areas (exhibition, staff-only, etc.)
areas: feature => {
let closed = feature.get('closed') ?? 0; // By default, use the feature's geoJSON "closed" property, or 0 (open) as a fallback
// But if this feature is in our CMS, use that instead
if (CMSData[feature.getId()]) {
switch (CMSData[feature.getId()].fields.closed) {
case 'Open': // Open to the public
closed = 0;
break;
case 'Staff-Only': // Always closed to the public (staff areas, etc.)
closed = 2;
break;
case 'Closed': // Temporarily closed due to COVID. We experimented with different styling but ultimately opted against it.
closed = 1;
break;
}
}
switch (closed) {
/*
// Diagonal gray stripes (not currently used)
// Previously used for areas temporarily closed due to COVID, but it became too visually confusing
// So we just mark all closed areas with the same solid gray
case 1:
return new Style({
// https://viglino.github.io/ol-ext/doc/doc-pages/ol.style.FillPattern.html
fill: new FillPattern({
pattern: 'hatch',
size: tfmStrokes.narrow,
angle: 45,
color: tfmColors["Field Gray Light"],
scale: 0.5,
}),
stroke: new Stroke({
color: tfmColors["Field Gray Light"],
width: tfmStrokes.narrow,
}),
})
*/
// Solid gray (closed and staff areas)
case 1:
case 2:
return new Style({
// https://viglino.github.io/ol-ext/doc/doc-pages/ol.style.FillPattern.html
fill: new Fill({
color: tfmColors["Field Gray Light"],
}),
stroke: new Stroke({
color: tfmColors["Field Gray Light"],
width: tfmStrokes.narrow,
}),
/*
text: new Text({
text: feature.get('label') ? (tfmView.getZoom() >= tfmZooms.close ? feature.get('label') + '\n(Closed)': null) : null,
font: (tfmView.getZoom() >= tfmZooms.close ? 1.2 / tfmView.getResolution() : 8) + 'pt Graphik, sans-serif',
fill: new Fill({
color: tfmColors['Field Gray'],
}),
overflow: true,
offsetY: 50,
}),
*/
})
// Open (white background, gray label text if specified in the geoJSON)
default:
const color = CMSData[feature.getId()] ? tfmColors[CMSData[feature.getId()].fields.color] + '88' : 'white';
return new Style({
stroke: new Stroke({
color: tfmColors["Field Gray Light"],
width: tfmStrokes.narrow,
}),
fill: new Fill({
color: 'white',
}),
text: new Text({
text: feature.get('label') ? (tfmView.getZoom() >= tfmZooms.medium ? feature.get('label') : '') : null,
font: (tfmView.getZoom() >= tfmZooms.close ? 1.2 / tfmView.getResolution() : 8) + 'pt Graphik, sans-serif',
fill: new Fill({
color: tfmColors['Field Gray'],
}),
overflow: true,
opacity: (0.1 / tfmView.getResolution()) + 0.25,
}),
})
}
}
};
// Layer settings by layer type
// This is where we define the settings per layer type
const LayerSettings = {
labels: {
tfmClickable: true,
tfmLayerType: "label",
minZoom: tfmZooms.far,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: feature => LayerStyles.labels(feature),
},
pictograms: {
minZoom: tfmZooms.far,
tfmLayerType: "pictogram",
tfmClickable: true,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: feature => LayerStyles.pictograms(feature),
},
flows: {
minZoom: tfmZooms.far,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: feature => LayerStyles.flows(feature),
},
Footprint: {
minZoom: tfmZooms.far,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: LayerStyles.Footprint
},
areas: {
tfmClickable: true,
tfmLayerType: "area",
minZoom: tfmZooms.far,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: feature => LayerStyles.areas(feature),
},
outline: {
minZoom: tfmZooms.far,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: LayerStyles.outline
},
amenities: {
minZoom: tfmZooms.far,
updateWhileInteracting: true,
updateWhileAnimating: true,
style: feature => LayerStyles.amenities(feature)
},
};
/*
This is where we actually initiate the layers, combining their sources and their settings (which also include their styles).
A "floor" is a just a group of layers for a specific floor of the museum, but we group them for easy switching
Each floor should at least have a solid footprint and areas, otherwise the base layer (OSM/Google Maps) will show through
The other stuff (flows, labels, amenities, pictograms, etc.) are helpful for visitors but not strictly necessary if you want to disable them
Once they are added here, they also automatically get added to the floor switcher based on `LayerGroups`. Setting a group's type to `base` will make them
mutually exclusive (only one base can be selected at a time). This behavior is due to change in a future version of `ol-layerswitcher`.
*/
const Floors = {
upper: new LayerGroup({
title: 'Upper<br>Level',
type: 'base',
layers: [
new VectorLayer({source: LayerSources.upper.outline, ...LayerSettings.Footprint}),
new VectorLayer({source: LayerSources.upper.areas, ...LayerSettings.areas}),
new VectorLayer({source: LayerSources.upper.outline, ...LayerSettings.outline}),
new VectorLayer({source: LayerSources.upper.amenities, ...LayerSettings.amenities}),
new VectorLayer({source: LayerSources.upper.flows, ...LayerSettings.flows}),
new VectorLayer({source: LayerSources.upper.pictograms, ...LayerSettings.pictograms}),
new VectorLayer({source: LayerSources.upper.labels, ...LayerSettings.labels}),
]
}),
main: new LayerGroup({
title: 'Main<br>Level',
type: 'base',
layers: [
new VectorLayer({source: LayerSources.main.outline, ...LayerSettings.Footprint}),
new VectorLayer({source: LayerSources.main.areas, ...LayerSettings.areas}),
new VectorLayer({source: LayerSources.main.outline, ...LayerSettings.outline}),
new VectorLayer({source: LayerSources.main.amenities, ...LayerSettings.amenities}),
new VectorLayer({source: LayerSources.main.flows, ...LayerSettings.flows}),
new VectorLayer({source: LayerSources.main.pictograms, ...LayerSettings.pictograms}),
new VectorLayer({source: LayerSources.main.labels, ...LayerSettings.labels}),
]
}),
ground: new LayerGroup({
title: 'Ground<br>Level',
type: 'base',
layers: [
new VectorLayer({source: LayerSources.ground.outline, ...LayerSettings.Footprint}),
new VectorLayer({source: LayerSources.ground.areas, ...LayerSettings.areas}),
new VectorLayer({source: LayerSources.ground.outline, ...LayerSettings.outline}),
new VectorLayer({source: LayerSources.ground.amenities, ...LayerSettings.amenities}),
new VectorLayer({source: LayerSources.ground.flows, ...LayerSettings.flows}),
new VectorLayer({source: LayerSources.ground.pictograms, ...LayerSettings.pictograms}),
new VectorLayer({source: LayerSources.ground.labels, ...LayerSettings.labels}),
]
}),
};
// Amenity icons
// Note that the "require" functionality here comes from Parcel. If you were using webpack or another bundler, you may have to change this.
const tfmIcons = {
atm: require('~/assets/icons/atm.svg'),
picnic_area: require('~/assets/icons/picnic_area.svg'),
elevator: require('~/assets/icons/elevator.svg'),
first_aid: require('~/assets/icons/first_aid.svg'),
guest_services: require('~/assets/icons/guest_services.svg'),
restaurant: require('~/assets/icons/restaurant.svg'),
restroom: require('~/assets/icons/restroom.svg'),
restroom_female: require('~/assets/icons/restroom_female.svg'),
restroom_male: require('~/assets/icons/restroom_male.svg'),
stairs_down: require('~/assets/icons/stairs_down.svg'),
stairs_up: require('~/assets/icons/stairs_up.svg'),
stairs_up_down: require('~/assets/icons/stairs_up_down.svg'),
store: require('~/assets/icons/store.svg'),
stroller: require('~/assets/icons/stroller.svg'),
wheelchair: require('~/assets/icons/wheelchair.svg')
};
// Pictograms (animals, dinosaurs, etc.)
// Note that the "require" functionality here comes from Parcel. If you were using webpack or another bundler, you may have to change this.
// For the open source release, we had to replace our pictogram assets with a placeholder SVG. That's what "example tardigrade" is.
const tfmPictograms = {
bird: require('~/assets/icons/tardigrade.svg'),
maximo: require('~/assets/icons/tardigrade.svg'),
lion: require('~/assets/icons/tardigrade.svg'),
totems: require('~/assets/icons/tardigrade.svg'),
elephant: require('~/assets/icons/tardigrade.svg'),
mask: require('~/assets/icons/tardigrade.svg'),
pawnee_lodge: require('~/assets/icons/tardigrade.svg'),
bushman: require('~/assets/icons/tardigrade.svg'),
sarcophagus: require('~/assets/icons/tardigrade.svg'),
sue: require('~/assets/icons/tardigrade.svg'),
trike: require('~/assets/icons/tardigrade.svg'),
stone_lion: require('~/assets/icons/tardigrade.svg'),
maori_house: require('~/assets/icons/tardigrade.svg'),
};
/*** SET UP THE VIEW AND MAP ***/
// Configure the starting view
// Desktop starts zoomed out and centered
let startingCenter = [-9753489.474583665, 5140948.158950368];
let startingZoom = tfmZooms.medium - 0.1;
// Mobile view starts at east entrance
if (window.innerWidth < 568
) {
startingCenter = [-9753360.67205395, 5140955.575372018]
startingZoom = tfmZooms.close - 0.7;
}
// Create the view
const tfmView = new View({
center: startingCenter,
zoom: startingZoom,
maxZoom: 27,
minZoom: 16,
constrainRotation: false, // Allow micro-adjusting the rotation so the museum doesn't look skewed
rotation: 0.95 * Math.PI / 180, // To account for slight skew of Chicago's grid, which is off true north
enableRotation: true, // Don't allow user to rotate map
});
// Create the map
const tfmMap = new Map({
layers: [
// Exterior basemap
new TileLayer({
source: new OSM({
attributions: ['Built with <a href="https://openlayers.org/" target="_blank">OpenLayers</a> and <a href="https://qgis.org/" target="_blank">QGIS</a><br>Exterior map © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap contributors</a>'],
}),
opacity: 0.25,
}),
// Warning/secret layer in case the floor picker accidentally unselects all floors
// This is necessary due to https://github.com/walkermatt/ol-layerswitcher/pull/360
new VectorLayer({
minZoom: tfmZooms.far,
source: LayerSources.upper.outline,
style: LayerStyles.WarningLayer,
updateWhileInteracting: true,
}),
// Simple museum outline for zoom levels <=16
new VectorLayer({
maxZoom: tfmZooms.far,
source: LayerSources.ground.outline,
updateWhileInteracting: true,
style: LayerStyles.ZoomedOutFootprint
}),
Floors.upper,
Floors.main,
Floors.ground
],
interactions: defaultInteractions({
altShiftDragRotate: false,
pinchRotate: false,
doubleClickZoom: true
}),
target: 'map',
keyboardEventTarget: document,
view: tfmView,
});
// Add layerswitcher controls
// https://github.com/walkermatt/ol-layerswitcher#api
// We use this as the floor switcher
let layerSwitcher = new LayerSwitcher(
{
reverse: false,
startActive: true,
activationMode: 'click',
groupSelectStyle: 'group',
}
);
tfmMap.addControl(layerSwitcher);
layerSwitcher.showPanel();
// Once the map renders
tfmMap.once('rendercomplete', function (event) {
zoomToHash(); // Go to the area specified in the URL, if any
// Preload sidebar images
Object.entries(CMSData).forEach(([k, v]) => {
if (v.fields && v.fields.imageUrl) {
const thumbnailUrl = shrinkImage(v.fields.imageUrl);
preloadImage(thumbnailUrl);
}
});
tfmMap.renderSync(); // Redraw the map
});
window.addEventListener('hashchange', zoomToHash); // If someone manually types in a new hash with the map open
/*** CLICK HANDLER ***/
// We use map.onClick instead of OL's selection event because this seems more reliable... the selection event
// doesn't always catch clicks outside of features, so closing the sidebar becomes hard
tfmMap.on('click', e => {
if (window.tfmDebug) console.log('Click event:', e);
let counter = 0; // Ideally we want to find one feature only, not zero or more than one, but there isn't a forFirstFeatureAtPixel event
tfmMap.forEachFeatureAtPixel(e.pixel, (feature, layer) => {
if (counter > 0) return; // We only want the first feature, not all the ones beneath it
counter++;
// If nothing was clicked on, close the sidebar and return
if ((!feature && !layer) || !feature.getId() || !CMSData[feature.getId()]) {
closeSidebar();
return;
}
// Setup
const currentLevel = Floors.upper.getVisible() ? 'upper' : Floors.main.getVisible() ? 'main' : Floors.ground.getVisible() ? 'ground' : null;
const tfmLayerType = layer.get('tfmLayerType');
const id = feature.getId();
const data = CMSData[id] ? CMSData[id].fields : null;
// Debug info
if (window.tfmDebug) {
console.log(`Clicked on feature "${feature.getId()}", data:`, data);
console.log(`Located in layer (type: ${tfmLayerType ?? undefined}):`, layer);
}
// Do different things depending on the layer type that was clicked on.
// tfmLayerType is a custom parameter we set to each layer in LayerSettings{}
switch (tfmLayerType) {
// Exhibitions and other areas
case 'area':
// Closed areas should not be clickable
if (data && (data.closed === 'Closed' || data.closed === 'Staff-Only')) return; // Check CMS first
else if (feature.get('closed') > 0) return;
// Only focus if it's in our CMS (i.e. has content added by an editor)
if (data) {
zoomToFeature(feature, currentLevel);
setHash(currentLevel, id);
}
break;
case 'label':
// Try to find a matching pictogram on this floor, with Contentful data too
if (LayerSources[currentLevel].pictograms.getFeatureById(id) && data) {
zoomToFeature(LayerSources[currentLevel].pictograms.getFeatureById(id));
setHash(currentLevel, id);
}
// Otherwise, assume it's an area
else {
zoomToFeature(LayerSources[currentLevel].areas.getFeatureById(id), currentLevel);
setHash(currentLevel, id)
}
break;
case 'pictogram':
// Closed areas should not be clickable
if (data && (data.closed === 'Closed' || data.closed === 'Staff-Only')) return;
if (id && data) {
zoomToFeature(feature);
setHash(currentLevel, id);
}
break;
}
}, {
layerFilter: layer => {
return layer.get('tfmClickable');
},
});
if (!counter) closeSidebar(); // If no features were detected at all
})
/*** KEYBOARD SHORTCUTS ***/
document.onkeydown = function (evt) {
evt = evt || window.event; // janky polyfill
switch (evt.key) {
case "Escape":
case "Esc":
closeSidebar();
break;
case "1":
closeSidebar();
switchFloors('ground');
fitFloor('ground');
break;
case "2":
closeSidebar();
switchFloors('main');
fitFloor('main');
break;
case "3":
closeSidebar();
switchFloors('upper');
fitFloor('upper');
break;
// By default, OpenLayers only uses the + sign for zooming. This code makes the = sign also emulate
// that behavior so you don't have to hold down the shift key
case "=":
tfmView.animate({zoom: tfmView.getZoom() + 1, duration: 100});
break;
case "0":
closeSidebar();
break;
}
};
/*** DEBUG HELPERS ***/
// For easier debugging inside the console
if (window.tfmDebug) {
window.tfmMap = tfmMap;
window.tfmView = tfmView;
window.contentfulData = CMSData;
}
/*** HELPER FUNCTIONS ***/
// Zoom to a specified feature (usually an area or pictogram)
// In the case of areas, also highlight them with a different color, basically a desaturated version of its specified color
let lastZoomedFeature;
function zoomToFeature(feature, currentLevel = null) {
const id = feature.getId();
const geometry = feature.getGeometry();
const data = CMSData[id];
resetHighlight();
// Map padding
let myPadding = [20, 400, 20, 20];
let myZoom = tfmZooms.close;
// For smaller devices
if (window.innerWidth <= 500) {
myZoom = tfmZooms.medium;
// Set the bottom padding so the popup doesn't hide the exhibition
myPadding = [10, 10, window.innerHeight * .7 + 10, 10]; // Equal to the sidebar.open height (in vh units) in index.css, line 236 or so
}
// If there's an area with a matching ID, highlight it
// We have to do this search because OpenLayers doesn't have an easy way to return a layer from a feature
if (currentLevel && LayerSources[currentLevel].areas.getFeatureById(id)) {
// Highlight areas with a lighter version of its color
if (!data) return;
if (!data.fields.color) data.fields.color = "Error Red"; // Set default color
feature.setStyle(
new Style({
fill: new Fill({
color: tfmColors[data.fields.color] + '88', // Approx 50% opacity (last two digits of hex code specify opacity)
}),
/* text: new Text({
text: selectedFeature.get('label') ? (tfmView.getZoom() >= tfmZooms.medium ? selectedFeature.get('label') : '') : null,
font: "bold 10pt Graphik, sans-serif",
fill: new Fill({
color: 'white',
}),
overflow: true,
opacity: (0.1 / tfmView.getResolution()) + 0.25,
}),
*/
})
);
}
// Zoom to fit the feature
tfmView.fit(geometry, {maxZoom: myZoom, duration: 500, padding: myPadding})
createSidebar(data);
openSidebar();
lastZoomedFeature = feature;
}
// Create/re-create the sidebar with CMS data for a given POI
// Does NOT open it.
function createSidebar(poi) {
// Don't open the sidebar if this is invalid
if (!poi || !poi.fields) {
if (window.tfmDebug) console.warn('Invalid POI for sidebar creation. POI should be a Contentful API entry.', poi);
return false;
}
// Resize image
const imageURL = shrinkImage(poi.fields.imageUrl) ?? null;
let sidebarText = "<div id='sidebar-close-button'>&#10005;</div>";
sidebarText += "<div id='sidebar-content'>";
sidebarText += "<h2>" + poi.fields.fullName + "</h2>";
// Add a link if it's provided in the CMS
const linkURL = poi.fields && poi.fields.websiteUrl ? new URL(poi.fields.websiteUrl) : false;
if (linkURL) {
// Add UTM params to our outgoing URLs
const utmParams = {
source: "field",
medium: "map",
};
Object.entries(utmParams).forEach(([k, v]) => linkURL.searchParams.set(`utm_${k}`, v));
// Add the link
sidebarText += `<p><a href='${linkURL.href}'><img src='${imageURL}'"></p>`;
sidebarText += `<button style="background-color: ${tfmColors[poi.fields.color]};" class="poi-learn-more-button">Learn More</button></a>`;
} else {
sidebarText += `<p><img src='${imageURL}'"></p>`;
}
sidebarText += `<div id="sidebar-poi-description">${documentToHtmlString(poi.fields.shortDescription)}</div>`;
sidebarText += '<p><a href="#" id="sidebar-go-back-link">« Return to map</a></p>';
if (window.tfmDebug === true) {
sidebarText += '<h2>Debug info</h2>';
sidebarText += '<ul>';
sidebarText += `<li>Short Name: ${poi.fields.shortName}</li>`;
sidebarText += `<li>Contentful ID: <a href='https://app.contentful.com/spaces/sq6jwxz7772c/entries/${poi.sys.id}' target="_blank">${poi.sys.id}</a></li>`;
sidebarText += `<li>Drupal Node: <a href="https://www.fieldmuseum.org/node/${poi.fields.nodeId}/edit" target="_blank">${poi.fields.nodeId}</a></li>`;
sidebarText += `<li>Color: <span style="background-color: ${tfmColors[poi.fields.color]}; color: white; padding: 0 5px">${poi.fields.color}</span></li>`;
sidebarText += '</ul>';
}
sidebarText += "<div>";
// Replace the sidebar
document.getElementById('tfm-sidebar').dataset.featureId = poi.sys.id;
document.getElementById('tfm-sidebar').style.borderColor = tfmColors[poi.fields.color];
document.getElementById('tfm-sidebar').innerHTML = sidebarText;
// Add event listeners
document.getElementById('tfm-sidebar').scrollTop = 0; // Scroll back up to the top
document.getElementById('sidebar-close-button').addEventListener('click', closeSidebar);
document.getElementById('sidebar-go-back-link').addEventListener('click', closeSidebar);
// Also set document title to indicate current sidebar
document.title = poi.fields.shortName + " - Field Museum Map";
}
// Open the sidebar. Use createSidebar() to create or update it first.
function openSidebar() {
document.getElementById('tfm-sidebar').className = 'open';
}
// Close the sidebar, clear highlights, reset the title and hash
function closeSidebar() {
resetHighlight();
document.getElementById('tfm-sidebar').className = 'closed';
document.title = "Field Museum Map";
location.hash = '';
}
// Update the URL hash (like #ground.egypt)
function setHash(level, id) {
location.hash = level + '.' + id;
}
// Reset previously selected feature if one already exists
function resetHighlight() {
if (lastZoomedFeature) {
lastZoomedFeature.setStyle(undefined); // Reset style
lastZoomedFeature = null;
}
}
// Manually switch to one floor or another
// Note that ol-layerswitcher does NOT do this; it has its own internal logic
// But it doesn't expose its methods, so we have to try to recreate it on our own
// We use this function in permalinks and keyboard shortcuts
function switchFloors(level) {
Object.entries(Floors).forEach(([floor, data]) => {
Floors[floor].setVisible(floor === level)
});
layerSwitcher.renderPanel(); // Resync the layer switcher
}
// Fit the entire floor in the viewport
// Right now only used by the keyboard shortcuts
// You might want to use this to zoom back out to the whole floor whenever a sidebar is closed... maybe?
function fitFloor(level) {
tfmMap.once('rendercomplete', () => {
tfmView.fit(LayerSources[level].outline.getExtent(), {padding: [50, 50, 50, 50], duration: 500})
});
}
// Given a floor and/or exhibition (like #ground.egypt), zoom to it and pull up its sidebar, if relevant
function zoomToHash() {
if (location.hash === undefined || location.hash === '') return;
const hash = location.hash.substring(1);
const matches = hash.match(/(.+?)(?:\.(.+))?$/); // expected format is #level.feature_id, like #main.maximo or #upper.evolving_planet
const level = matches[1];
const id = matches[2];
// Make the specified level visible
if (level && Object.keys(Floors).includes(level)) {
switchFloors(level);
// If an ID was also specified, zoom to it
if (id) {
tfmMap.once('rendercomplete', () => {
if (LayerSources[level].areas.getFeatureById(id)) {
zoomToFeature(LayerSources[level].areas.getFeatureById(id), level);
} else if (LayerSources[level].pictograms.getFeatureById(id)) {
zoomToFeature(LayerSources[level].pictograms.getFeatureById(id))
} else {
console.warn(`Warning: Cannot zoom to invalid id "${id}" on ${level} floor`);
}
});
}
// Otherwise just fit the entire level
else {
fitFloor(level);
}
} else {
console.warn('Warning: Could not zoom to invalid floor: ', level);
console.info('Valid floors are: ', Object.keys(Floors).join(", "));
}
}
// This preloads all the sidebar images
// Don't call this until the map is fully rendered, or you'd impact load time
function preloadImage(url) {
try {
var _img = new Image();
_img.src = url;
} catch (e) {
if (window.tfmDebug) console.warn(e);
}
}
// Generate a thumbnail using cloud services
function shrinkImage(url) {
const newURL = new URL(url);
// Use Drupal image styles if this is one of ours (to save CDN bandwidth)
if (newURL.host === 'www.fieldmuseum.org') {
newURL.pathname = newURL.pathname.replace(/\/sites\/default\/files\/(styles\/.+\/public\/)?/, '/sites/default/files/styles/gallery_500w/public/');
return newURL.href
}
// Else use Cloudinary as an image CDN (can also replace this with imgix, etc.)
else {
// return 'https://res.cloudinary.com/your_cloudinary_account/image/fetch/w_500/' + newURL.href;
return newURL.href;
}
}