-
Notifications
You must be signed in to change notification settings - Fork 12
/
index.js
1144 lines (992 loc) · 45.1 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// 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'>✕</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>`;