@panoramax/web-viewer 3.0.2-develop-a8ea8e60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.dockerignore +6 -0
  2. package/.gitlab-ci.yml +71 -0
  3. package/CHANGELOG.md +428 -0
  4. package/CODE_OF_CONDUCT.md +134 -0
  5. package/Dockerfile +14 -0
  6. package/LICENSE +21 -0
  7. package/README.md +39 -0
  8. package/build/editor.html +1 -0
  9. package/build/index.css +36 -0
  10. package/build/index.css.map +1 -0
  11. package/build/index.html +1 -0
  12. package/build/index.js +25 -0
  13. package/build/index.js.map +1 -0
  14. package/build/map.html +1 -0
  15. package/build/viewer.html +1 -0
  16. package/config/env.js +104 -0
  17. package/config/getHttpsConfig.js +66 -0
  18. package/config/getPackageJson.js +25 -0
  19. package/config/jest/babelTransform.js +29 -0
  20. package/config/jest/cssTransform.js +14 -0
  21. package/config/jest/fileTransform.js +40 -0
  22. package/config/modules.js +134 -0
  23. package/config/paths.js +72 -0
  24. package/config/pnpTs.js +35 -0
  25. package/config/webpack/persistentCache/createEnvironmentHash.js +9 -0
  26. package/config/webpack.config.js +885 -0
  27. package/config/webpackDevServer.config.js +127 -0
  28. package/docs/01_Start.md +149 -0
  29. package/docs/02_Usage.md +828 -0
  30. package/docs/03_URL_settings.md +140 -0
  31. package/docs/04_Advanced_examples.md +214 -0
  32. package/docs/05_Compatibility.md +85 -0
  33. package/docs/09_Develop.md +62 -0
  34. package/docs/90_Releases.md +27 -0
  35. package/docs/images/class_diagram.drawio +129 -0
  36. package/docs/images/class_diagram.jpg +0 -0
  37. package/docs/images/screenshot.jpg +0 -0
  38. package/mkdocs.yml +45 -0
  39. package/package.json +254 -0
  40. package/public/editor.html +54 -0
  41. package/public/favicon.ico +0 -0
  42. package/public/index.html +59 -0
  43. package/public/map.html +53 -0
  44. package/public/viewer.html +67 -0
  45. package/scripts/build.js +217 -0
  46. package/scripts/start.js +176 -0
  47. package/scripts/test.js +52 -0
  48. package/src/Editor.css +37 -0
  49. package/src/Editor.js +359 -0
  50. package/src/StandaloneMap.js +114 -0
  51. package/src/Viewer.css +203 -0
  52. package/src/Viewer.js +1186 -0
  53. package/src/components/CoreView.css +64 -0
  54. package/src/components/CoreView.js +159 -0
  55. package/src/components/Loader.css +56 -0
  56. package/src/components/Loader.js +111 -0
  57. package/src/components/Map.css +65 -0
  58. package/src/components/Map.js +841 -0
  59. package/src/components/Photo.css +36 -0
  60. package/src/components/Photo.js +687 -0
  61. package/src/img/arrow_360.svg +14 -0
  62. package/src/img/arrow_flat.svg +11 -0
  63. package/src/img/arrow_triangle.svg +10 -0
  64. package/src/img/arrow_turn.svg +9 -0
  65. package/src/img/bg_aerial.jpg +0 -0
  66. package/src/img/bg_streets.jpg +0 -0
  67. package/src/img/loader_base.jpg +0 -0
  68. package/src/img/loader_hd.jpg +0 -0
  69. package/src/img/logo_dead.svg +91 -0
  70. package/src/img/marker.svg +17 -0
  71. package/src/img/marker_blue.svg +20 -0
  72. package/src/img/switch_big.svg +44 -0
  73. package/src/img/switch_mini.svg +48 -0
  74. package/src/index.js +10 -0
  75. package/src/translations/de.json +163 -0
  76. package/src/translations/en.json +164 -0
  77. package/src/translations/eo.json +6 -0
  78. package/src/translations/es.json +164 -0
  79. package/src/translations/fi.json +1 -0
  80. package/src/translations/fr.json +164 -0
  81. package/src/translations/hu.json +133 -0
  82. package/src/translations/nl.json +1 -0
  83. package/src/translations/zh_Hant.json +136 -0
  84. package/src/utils/API.js +709 -0
  85. package/src/utils/Exif.js +198 -0
  86. package/src/utils/I18n.js +75 -0
  87. package/src/utils/Map.js +382 -0
  88. package/src/utils/PhotoAdapter.js +45 -0
  89. package/src/utils/Utils.js +568 -0
  90. package/src/utils/Widgets.js +477 -0
  91. package/src/viewer/URLHash.js +334 -0
  92. package/src/viewer/Widgets.css +711 -0
  93. package/src/viewer/Widgets.js +1196 -0
  94. package/tests/Editor.test.js +125 -0
  95. package/tests/StandaloneMap.test.js +44 -0
  96. package/tests/Viewer.test.js +363 -0
  97. package/tests/__snapshots__/Editor.test.js.snap +300 -0
  98. package/tests/__snapshots__/StandaloneMap.test.js.snap +30 -0
  99. package/tests/__snapshots__/Viewer.test.js.snap +195 -0
  100. package/tests/components/CoreView.test.js +91 -0
  101. package/tests/components/Loader.test.js +38 -0
  102. package/tests/components/Map.test.js +230 -0
  103. package/tests/components/Photo.test.js +335 -0
  104. package/tests/components/__snapshots__/Loader.test.js.snap +15 -0
  105. package/tests/components/__snapshots__/Map.test.js.snap +767 -0
  106. package/tests/components/__snapshots__/Photo.test.js.snap +205 -0
  107. package/tests/data/Map_geocoder_ban.json +36 -0
  108. package/tests/data/Map_geocoder_nominatim.json +56 -0
  109. package/tests/data/Viewer_pictures_1.json +148 -0
  110. package/tests/setupTests.js +5 -0
  111. package/tests/utils/API.test.js +906 -0
  112. package/tests/utils/Exif.test.js +124 -0
  113. package/tests/utils/I18n.test.js +28 -0
  114. package/tests/utils/Map.test.js +105 -0
  115. package/tests/utils/Utils.test.js +300 -0
  116. package/tests/utils/Widgets.test.js +107 -0
  117. package/tests/utils/__snapshots__/API.test.js.snap +132 -0
  118. package/tests/utils/__snapshots__/Exif.test.js.snap +43 -0
  119. package/tests/utils/__snapshots__/Map.test.js.snap +48 -0
  120. package/tests/utils/__snapshots__/Utils.test.js.snap +41 -0
  121. package/tests/utils/__snapshots__/Widgets.test.js.snap +44 -0
  122. package/tests/viewer/URLHash.test.js +537 -0
  123. package/tests/viewer/Widgets.test.js +127 -0
  124. package/tests/viewer/__snapshots__/URLHash.test.js.snap +98 -0
  125. package/tests/viewer/__snapshots__/Widgets.test.js.snap +393 -0
@@ -0,0 +1,841 @@
1
+ import "./Map.css";
2
+ import {
3
+ forwardGeocodingBAN, forwardGeocodingNominatim, VECTOR_STYLES,
4
+ TILES_PICTURES_ZOOM, getThumbGif, RASTER_LAYER_ID, combineStyles,
5
+ getMissingLayerStyles, isLabelLayer, getUserLayerId, getUserSourceId,
6
+ } from "../utils/Map";
7
+ import { COLORS } from "../utils/Utils";
8
+ import MarkerBaseSVG from "../img/marker.svg";
9
+ import MarkerSelectedSVG from "../img/marker_blue.svg";
10
+ import ArrowFlatSVG from "../img/arrow_flat.svg";
11
+ import Arrow360SVG from "../img/arrow_360.svg";
12
+
13
+ // MapLibre imports
14
+ import "maplibre-gl/dist/maplibre-gl.css";
15
+ import maplibregl from "!maplibre-gl"; // DO NOT REMOVE THE "!": bundled builds breaks otherwise !!!
16
+ import maplibreglWorker from "maplibre-gl/dist/maplibre-gl-csp-worker";
17
+ import * as pmtiles from "pmtiles";
18
+ maplibregl.workerClass = maplibreglWorker;
19
+ maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile);
20
+
21
+ const MAPLIBRE_OPTIONS = [ // No "style" option as it's handled by combineStyles function
22
+ "antialias", "attributionControl", "bearing", "bearingSnap", "bounds",
23
+ "boxZoom", "center", "clickTolerance", "collectResourceTiming",
24
+ "cooperativeGestures", "crossSourceCollisions", "doubleClickZoom", "dragPan",
25
+ "dragRotate", "fadeDuration", "failIfMajorPerformanceCaveat", "fitBoundsOptions",
26
+ "hash", "interactive", "keyboard", "localIdeographFontFamily", "locale", "logoPosition",
27
+ "maplibreLogo", "maxBounds", "maxCanvasSize", "maxPitch", "maxTileCacheSize",
28
+ "maxTileCacheZoomLevels", "maxZoom", "minPitch", "minZoom", "pitch", "pitchWithRotate",
29
+ "pixelRatio", "preserveDrawingBuffer", "refreshExpiredTiles", "renderWorldCopies",
30
+ "scrollZoom", "touchPitch", "touchZoomRotate", "trackResize",
31
+ "transformCameraUpdate", "transformRequest", "validateStyle", "zoom"
32
+ ];
33
+ const filterMapLibreOptions = opts => Object.fromEntries(Object.entries(opts).filter(([key]) => MAPLIBRE_OPTIONS.includes(key)));
34
+
35
+
36
+ /**
37
+ * Map is the component showing pictures and sequences geolocation.
38
+ *
39
+ * Note that all functions of [MapLibre GL JS class Map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/) are also available.
40
+ *
41
+ * @param {CoreView} parent The parent view
42
+ * @param {Element} container The DOM element to create into
43
+ * @param {object} [options] The map options (any of [MapLibre GL settings](https://maplibre.org/maplibre-gl-js-docs/api/map/#map-parameters) or any supplementary option defined here)
44
+ * @param {object} [options.raster] The MapLibre raster source for aerial background. This must be a JSON object following [MapLibre raster source definition](https://maplibre.org/maplibre-style-spec/sources/#raster).
45
+ * @param {string} [options.background] Choose default map background to display (streets or aerial, if raster aerial background available). Defaults to street.
46
+ * @param {object} [options.geocoder] Optional geocoder settings
47
+ * @param {string} [options.geocoder.engine] Set the geocoder engine to use (nominatim, ban)
48
+ */
49
+ export default class Map extends maplibregl.Map {
50
+ constructor(parent, container, options = {}) {
51
+ super({
52
+ container: container,
53
+ style: combineStyles(parent, options),
54
+ center: [0,0],
55
+ zoom: 0,
56
+ maxZoom: 24,
57
+ attributionControl: false,
58
+ dragRotate: false,
59
+ pitchWithRotate: false,
60
+ preserveDrawingBuffer: !parent.isWidthSmall(),
61
+ transformRequest: parent._api._getMapRequestTransform(),
62
+ locale: parent._t.maplibre,
63
+ ...filterMapLibreOptions(options)
64
+ });
65
+ this._loadMarkerImages();
66
+
67
+ this._parent = parent;
68
+ this._options = options;
69
+ this.getContainer().classList.add("gvs-map");
70
+
71
+ // Handle raster source
72
+ if(this._options.raster) {
73
+ this._options.background = this._options.background || "streets";
74
+ }
75
+
76
+ this._attribution = new maplibregl.AttributionControl({ compact: false });
77
+ this.addControl(this._attribution);
78
+
79
+ this._initGeocoder();
80
+ this._initMapPosition();
81
+
82
+ // Widgets and markers
83
+ this._picMarker = this._getPictureMarker();
84
+ this._picMarkerPreview = this._getPictureMarker(false);
85
+
86
+ // Cache for pictures and sequences thumbnails
87
+ this._picThumbUrl = {};
88
+ this._seqPictures = {};
89
+
90
+ // Sequences and pictures per users
91
+ this._userLayers = new Set();
92
+
93
+ // Hover event
94
+ this.on("mousemove", "sequences", this._onSequenceHover.bind(this));
95
+
96
+ // Parent selection
97
+ this._parent.addEventListener("select", this.reloadLayersStyles.bind(this));
98
+
99
+ this.on("load", async () => {
100
+ await this.setVisibleUsers(this._parent._options.users);
101
+ this.reloadLayersStyles();
102
+ this.resize();
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Destroy any form of life in this component
108
+ */
109
+ destroy() {
110
+ this.remove();
111
+ delete this._parent;
112
+ delete this._options;
113
+ delete this._attribution;
114
+ delete this._picMarker;
115
+ delete this._picMarkerPreview;
116
+ delete this._picThumbUrl;
117
+ delete this._seqPictures;
118
+ delete this._userLayers;
119
+ }
120
+
121
+ /**
122
+ * Sets map view based on returned API bbox (if no precise option given by user).
123
+ * @private
124
+ */
125
+ _initMapPosition() {
126
+ if(
127
+ (!this._options.center || this._options.center == [0,0])
128
+ && (!this._options.zoom || this._options.zoom === 0)
129
+ && (!this._options.hash)
130
+ ) {
131
+ this._parent._api.onceReady().then(() => {
132
+ let bbox = this._parent?._api?.getDataBbox();
133
+ if(bbox) {
134
+ try {
135
+ bbox = new maplibregl.LngLatBounds(bbox);
136
+ if(this.loaded()) { this.fitBounds(bbox, { "animate": false }); }
137
+ else { this.on("load", () => this.fitBounds(bbox, { "animate": false })); }
138
+ }
139
+ catch(e) {
140
+ console.warn("Received invalid bbox: "+bbox);
141
+ }
142
+ }
143
+ });
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Creates the geocoder search bar
149
+ * @private
150
+ */
151
+ _initGeocoder() {
152
+ const engines = { "ban": forwardGeocodingBAN, "nominatim": forwardGeocodingNominatim };
153
+ const engine = this._options?.geocoder?.engine || "nominatim";
154
+ this.geocoder = engines[engine];
155
+ this._geolocate = new maplibregl.GeolocateControl({
156
+ positionOptions: {
157
+ enableHighAccuracy: true,
158
+ timeout: 60000, // Max 1 minute for first position
159
+ maximumAge: 300000, // Accepts 5 minutes old position
160
+ },
161
+ showAccuracyCircle: true,
162
+ showUserLocation: true,
163
+ trackUserLocation: true,
164
+ }).onAdd(this);
165
+ }
166
+
167
+ /**
168
+ * Load markers into map for use in map layers.
169
+ * @private
170
+ */
171
+ _loadMarkerImages() {
172
+ [
173
+ { id: "gvs-marker", img: MarkerBaseSVG },
174
+ { id: "gvs-arrow-flat", img: ArrowFlatSVG },
175
+ { id: "gvs-arrow-360", img: Arrow360SVG },
176
+ ].forEach(m => {
177
+ const img = new Image(64, 64);
178
+ img.onload = () => this.addImage(m.id, img);
179
+ img.src = m.img;
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Force refresh of vector tiles data
185
+ */
186
+ reloadVectorTiles() {
187
+ [...this._userLayers].forEach(dl => {
188
+ const s = this.getSource(getUserSourceId(dl));
189
+ s.setTiles(s.tiles);
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Check if map offers aerial imagery as well as streets rendering.
195
+ * @returns {boolean} True if aerial imagery is available for display
196
+ */
197
+ hasTwoBackgrounds() {
198
+ return this.getLayer(RASTER_LAYER_ID) !== undefined;
199
+ }
200
+
201
+ /**
202
+ * Get the currently selected map background
203
+ * @returns {string} aerial or streets
204
+ */
205
+ getBackground() {
206
+ if(!this.getLayer(RASTER_LAYER_ID)) {
207
+ return "streets";
208
+ }
209
+
210
+ const aerialVisible = this.getLayoutProperty(RASTER_LAYER_ID, "visibility") == "visible";
211
+ return aerialVisible ? "aerial" : "streets";
212
+ }
213
+
214
+ /**
215
+ * Change the shown background in map.
216
+ * @param {string} bg The new background to display (aerial or streets)
217
+ */
218
+ setBackground(bg) {
219
+ if(!this.getLayer(RASTER_LAYER_ID) && bg === "aerial") { throw new Error("No aerial imagery available"); }
220
+ if(this.getLayer(RASTER_LAYER_ID)) {
221
+ this.setLayoutProperty(RASTER_LAYER_ID, "visibility", bg === "aerial" ? "visible" : "none");
222
+
223
+ /**
224
+ * Event for map background changes
225
+ *
226
+ * @event map:background-changed
227
+ * @memberof CoreView
228
+ * @type {object}
229
+ * @property {object} detail Event information
230
+ * @property {string} [detail.background] The new selected background (aerial, streets)
231
+ */
232
+ const event = new CustomEvent("map:background-changed", { detail: { background: bg || "streets" }});
233
+ this._parent.dispatchEvent(event);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Get the currently visible users
239
+ * @returns {string[]} List of visible users
240
+ */
241
+ getVisibleUsers() {
242
+ return [...this._userLayers].filter(l => (
243
+ this.getLayoutProperty(getUserLayerId(l, "pictures"), "visibility") === "visible"
244
+ ));
245
+ }
246
+
247
+ /**
248
+ * Make given user layers visible on map, and hide all others (if any)
249
+ *
250
+ * @param {string|string[]} visibleIds The user layers IDs to display
251
+ */
252
+ async setVisibleUsers(visibleIds = []) {
253
+ if(typeof visibleIds === "string") { visibleIds = [visibleIds]; }
254
+
255
+ // Create any missing user layer
256
+ await Promise.all(
257
+ visibleIds
258
+ .filter(id => id != "" && !this._userLayers.has(id))
259
+ .map(id => this._createPicturesTilesLayer(id))
260
+ );
261
+
262
+ // Switch visibility
263
+ const layersSuffixes = ["pictures", "sequences", "sequences_plus", "grid", "pictures_symbols"];
264
+ [...this._userLayers].forEach(l => {
265
+ layersSuffixes.forEach(suffix => {
266
+ const layerId = getUserLayerId(l, suffix);
267
+ if(this.getLayer(layerId)) {
268
+ this.setLayoutProperty(layerId, "visibility", visibleIds.includes(l) ? "visible" : "none");
269
+ }
270
+ });
271
+ });
272
+
273
+ /**
274
+ * Event for visible users changes
275
+ *
276
+ * @event map:users-changed
277
+ * @memberof CoreView
278
+ * @type {object}
279
+ * @property {object} detail Event information
280
+ * @property {string[]} [detail.usersIds] The list of newly selected users
281
+ */
282
+ const event = new CustomEvent("map:users-changed", { detail: { usersIds: visibleIds }});
283
+ this._parent.dispatchEvent(event);
284
+ }
285
+
286
+ /**
287
+ * Filter the visible data content in all visible map layers
288
+ * @param {string} dataType sequences or pictures
289
+ * @param {object} filter The MapLibre GL filter rule to apply
290
+ */
291
+ filterUserLayersContent(dataType, filter) {
292
+ [...this._userLayers].forEach(l => {
293
+ this.setFilter(getUserLayerId(l, dataType), filter);
294
+ if(dataType === "sequences") {
295
+ this.setFilter(getUserLayerId(l, "sequences_plus"), filter);
296
+ }
297
+ if(dataType === "pictures") {
298
+ this.setFilter(getUserLayerId(l, "pictures_symbols"), filter);
299
+ }
300
+ });
301
+ }
302
+
303
+ /**
304
+ * Shows on map a picture position and heading.
305
+ *
306
+ * @param {number} lon The longitude
307
+ * @param {number} lat The latitude
308
+ * @param {number} heading The heading
309
+ */
310
+ displayPictureMarker(lon, lat, heading) {
311
+ this._picMarkerPreview.remove();
312
+
313
+ // Show marker corresponding to selection
314
+ this._picMarker
315
+ .setLngLat([lon, lat])
316
+ .setRotation(heading)
317
+ .addTo(this);
318
+
319
+ // Update map style to see selected sequence
320
+ this.reloadLayersStyles();
321
+
322
+ // Move map to picture coordinates
323
+ this.flyTo({
324
+ center: [lon, lat],
325
+ zoom: this.getZoom() < TILES_PICTURES_ZOOM+2 ? TILES_PICTURES_ZOOM+2 : this.getZoom(),
326
+ maxDuration: 2000
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Forces reload of pictures/sequences layer styles.
332
+ * This is useful after a map theme change.
333
+ */
334
+ reloadLayersStyles() {
335
+ const updateStyle = (layer, style) => {
336
+ [...this._userLayers].forEach(l => {
337
+ for(let p in style.layout) {
338
+ this.setLayoutProperty(getUserLayerId(l, layer), p, style.layout[p]);
339
+ }
340
+ for(let p in style.paint) {
341
+ this.setPaintProperty(getUserLayerId(l, layer), p, style.paint[p]);
342
+ }
343
+ });
344
+ };
345
+ ["pictures", "pictures_symbols", "sequences"].forEach(l => {
346
+ updateStyle(l, this._getLayerStyleProperties(l));
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Creates source and layers for pictures and sequences.
352
+ * @private
353
+ * @param {string} id The source and layer ID prefix
354
+ */
355
+ async _createPicturesTilesLayer(id) {
356
+ this._userLayers.add(id);
357
+ const firstLabelLayerId = this.getStyle().layers.find(isLabelLayer);
358
+
359
+ // Load style from API
360
+ if(id !== "geovisio" && !this.getSource(`geovisio_${id}`)) {
361
+ const style = await this._parent._api.getUserMapStyle(id);
362
+ Object.entries(style.sources).forEach(([sId, s]) => this.addSource(sId, s));
363
+ style.layers = style.layers || [];
364
+ const layers = style.layers.concat(getMissingLayerStyles(style.sources, style.layers));
365
+ layers.forEach(l => this.addLayer(l, firstLabelLayerId?.id));
366
+ }
367
+
368
+ // Map interaction events
369
+ // Popup
370
+ this._picPreviewTimer = null;
371
+ this._picPopup = new maplibregl.Popup({
372
+ closeButton: false,
373
+ closeOnClick: !this._parent.isWidthSmall(),
374
+ offset: 3
375
+ });
376
+ this._picPopup.on("close", () => { delete this._picPopup._picId; });
377
+
378
+ // Pictures
379
+ const picLayerId = getUserLayerId(id, "pictures");
380
+ this.on("mousemove", picLayerId, e => {
381
+ this.getCanvas().style.cursor = "pointer";
382
+ const eCopy = Object.assign({}, e);
383
+ clearTimeout(this._picPreviewTimer);
384
+ this._picPreviewTimer = setTimeout(
385
+ () => this._attachPreviewToPictures(eCopy, picLayerId),
386
+ 100
387
+ );
388
+ });
389
+
390
+ this.on("mouseleave", picLayerId, () => {
391
+ clearTimeout(this._picPreviewTimer);
392
+ this.getCanvas().style.cursor = "";
393
+ this._picPopup._picId;
394
+ this._picPopup.remove();
395
+ });
396
+
397
+ this.on("click", picLayerId, this._onPictureClick.bind(this));
398
+
399
+ // Sequences
400
+ const seqPlusLayerId = getUserLayerId(id, "sequences_plus");
401
+ this.on("mousemove", seqPlusLayerId, e => {
402
+ if(this.getZoom() <= TILES_PICTURES_ZOOM+1) {
403
+ this.getCanvas().style.cursor = "pointer";
404
+ if(e.features[0].properties.id) {
405
+ const eCopy = Object.assign({}, e);
406
+ clearTimeout(this._picPreviewTimer);
407
+ this._picPreviewTimer = setTimeout(
408
+ () => this._attachPreviewToPictures(eCopy, seqPlusLayerId),
409
+ 100
410
+ );
411
+ }
412
+ }
413
+ });
414
+
415
+ this.on("mouseleave", seqPlusLayerId, () => {
416
+ clearTimeout(this._picPreviewTimer);
417
+ this.getCanvas().style.cursor = "";
418
+ this._picPopup._picId;
419
+ this._picPopup.remove();
420
+ });
421
+
422
+ this.on("click", seqPlusLayerId, e => {
423
+ e.preventDefault();
424
+ if(this.getZoom() <= TILES_PICTURES_ZOOM+1) {
425
+ this._onSequenceClick(e);
426
+ }
427
+ });
428
+
429
+ // Grid
430
+ if(id === "geovisio" && this.getLayer("geovisio_grid")) {
431
+ this.on("mousemove", "geovisio_grid", e => {
432
+ if(this.getZoom() <= TILES_PICTURES_ZOOM+1) {
433
+ this.getCanvas().style.cursor = "pointer";
434
+ const eCopy = Object.assign({}, e);
435
+ clearTimeout(this._picPreviewTimer);
436
+ this._picPreviewTimer = setTimeout(
437
+ () => this._attachPreviewToPictures(eCopy, "geovisio_grid"),
438
+ 100
439
+ );
440
+ }
441
+ });
442
+
443
+ this.on("mouseleave", "geovisio_grid", () => {
444
+ clearTimeout(this._picPreviewTimer);
445
+ this.getCanvas().style.cursor = "";
446
+ this._picPopup._picId;
447
+ this._picPopup.remove();
448
+ });
449
+
450
+ this.on("click", "geovisio_grid", e => {
451
+ e.preventDefault();
452
+ this.flyTo({ center: e.lngLat, zoom: TILES_PICTURES_ZOOM-6 });
453
+ });
454
+ }
455
+
456
+ // Map background click
457
+ this.on("click", (e) => {
458
+ if(e.defaultPrevented === false) {
459
+ clearTimeout(this._picPreviewTimer);
460
+ this._picPopup.remove();
461
+ }
462
+ });
463
+ }
464
+
465
+ /**
466
+ * MapLibre paint/layout properties for specific layer
467
+ * This is useful when selected picture changes to allow partial update
468
+ *
469
+ * @returns {object} Paint/layout properties
470
+ * @private
471
+ */
472
+ _getLayerStyleProperties(layer) {
473
+ if(layer === "pictures_symbols") {
474
+ return {
475
+ "paint": {},
476
+ "layout": {
477
+ "icon-image": ["case",
478
+ ["==", ["get", "id"], this._parent._selectedPicId], "",
479
+ ["==", ["get", "type"], "equirectangular"], "gvs-arrow-360",
480
+ "gvs-arrow-flat"
481
+ ],
482
+ "symbol-sort-key": this._getLayerSortStyle(layer),
483
+ },
484
+ };
485
+ }
486
+ else {
487
+ const prefixes = {
488
+ "pictures": "circle",
489
+ "sequences": "line",
490
+ };
491
+ return {
492
+ "paint": Object.assign({
493
+ [`${prefixes[layer]}-color`]: this._getLayerColorStyle(layer),
494
+ }, VECTOR_STYLES[layer.toUpperCase()].paint),
495
+ "layout": Object.assign({
496
+ [`${prefixes[layer]}-sort-key`]: this._getLayerSortStyle(layer),
497
+ }, VECTOR_STYLES[layer.toUpperCase()].layout)
498
+ };
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Retrieve map layer color scheme according to selected theme.
504
+ * @private
505
+ */
506
+ _getLayerColorStyle(layer) {
507
+ // Hidden style
508
+ const s = ["case",
509
+ ["==", ["get", "hidden"], true], COLORS.HIDDEN,
510
+ ["==", ["get", "geovisio:status"], "hidden"], COLORS.HIDDEN,
511
+ ];
512
+
513
+ // Selected sequence style
514
+ const seqId = this._parent._selectedSeqId;
515
+ if(layer == "sequences" && seqId) {
516
+ s.push(["==", ["get", "id"], seqId], COLORS.SELECTED);
517
+ }
518
+ else if(layer.startsWith("pictures") && seqId) {
519
+ s.push(["in", seqId, ["get", "sequences"]], COLORS.SELECTED);
520
+ }
521
+
522
+ // Classic style
523
+ s.push(COLORS.BASE);
524
+
525
+ return s;
526
+ }
527
+
528
+ /**
529
+ * Retrieve map sort key according to selected theme.
530
+ * @private
531
+ */
532
+ _getLayerSortStyle(layer) {
533
+ // Values
534
+ // - 100 : on top / selected feature
535
+ // - 90 : hidden feature
536
+ // - 20-80 : custom ranges
537
+ // - 10 : basic feature
538
+ // - 0 : on bottom / feature with undefined property
539
+ // Hidden style
540
+ const s = ["case",
541
+ ["==", ["get", "hidden"], true], 90
542
+ ];
543
+
544
+ // Selected sequence style
545
+ const seqId = this._parent._selectedSeqId;
546
+ if(layer == "sequences" && seqId) {
547
+ s.push(["==", ["get", "id"], seqId], 100);
548
+ }
549
+ else if(layer.startsWith("pictures") && seqId) {
550
+ s.push(["in", seqId, ["get", "sequences"]], 100);
551
+ }
552
+
553
+ s.push(10);
554
+ return s;
555
+ }
556
+
557
+ /**
558
+ * Creates popup manager for preview of pictures.
559
+ * @private
560
+ * @param {object} e The event thrown by MapLibre
561
+ * @param {string} from The event source layer
562
+ */
563
+ _attachPreviewToPictures(e, from) {
564
+ let f = e.features.pop();
565
+ if(!f || f.properties.id == this._picPopup._picId) { return; }
566
+
567
+ let coordinates = null;
568
+ if(from.endsWith("pictures")) { coordinates = f.geometry.coordinates.slice(); }
569
+ else if(e.lngLat) { coordinates = [e.lngLat.lng, e.lngLat.lat]; }
570
+
571
+ // If no coordinates found, find from geometry (nearest to map center)
572
+ if(!coordinates) {
573
+ const coords = f.geometry.type === "LineString" ? [f.geometry.coordinates] : f.geometry.coordinates;
574
+ let prevDist = null;
575
+ const mapBbox = this.getBounds();
576
+ const mapCenter = mapBbox.getCenter();
577
+ for(let i=0; i < coords.length; i++) {
578
+ for(let j=0; j < coords[i].length; j++) {
579
+ if(mapBbox.contains(coords[i][j])) {
580
+ let dist = mapCenter.distanceTo(new maplibregl.LngLat(...coords[i][j]));
581
+ if(prevDist === null || dist < prevDist) {
582
+ coordinates = coords[i][j];
583
+ prevDist = dist;
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ if(!coordinates) { return; }
590
+ }
591
+
592
+ // Display thumbnail
593
+ this._picPopup
594
+ .setLngLat(coordinates)
595
+ .addTo(this);
596
+
597
+ // Only show GIF loader if thumbnail is not in browser cache
598
+ if(!this._picThumbUrl[f.properties.id]) {
599
+ this._picPopup.setDOMContent(getThumbGif(this._parent._t));
600
+ }
601
+
602
+ this._picPopup._loading = f.properties.id;
603
+ this._picPopup._picId = f.properties.id;
604
+
605
+ const displayThumb = thumbUrl => {
606
+ if(this._picPopup._loading === f.properties.id) {
607
+ delete this._picPopup._loading;
608
+
609
+ if(thumbUrl) {
610
+ let content = document.createElement("img");
611
+ content.classList.add("gvs-map-thumb");
612
+ content.alt = this._parent._t.thumbnail;
613
+ let img = new Image();
614
+ img.src = thumbUrl;
615
+
616
+ img.addEventListener("load", () => {
617
+ if(f.properties.hidden) {
618
+ content.children[0].src = img.src;
619
+ }
620
+ else {
621
+ content.src = img.src;
622
+ }
623
+ this._picPopup.setDOMContent(content);
624
+ });
625
+
626
+ if(f.properties.hidden) {
627
+ const legend = document.createElement("div");
628
+ legend.classList.add("gvs-map-thumb-legend");
629
+ legend.appendChild(document.createTextNode(this._parent._t.map.not_public));
630
+ const container = document.createElement("div");
631
+ container.appendChild(content);
632
+ container.appendChild(legend);
633
+ content = container;
634
+ }
635
+ }
636
+ else {
637
+ this._picPopup.setHTML(`<i>${this._parent._t.no_thumbnail}</i>`);
638
+ }
639
+ }
640
+ };
641
+
642
+ // Click on a single picture
643
+ if(from.endsWith("pictures")) {
644
+ this._getPictureThumbURL(f.properties.id).then(displayThumb);
645
+ }
646
+ // Click on a grid cell
647
+ else if(from.endsWith("grid")) {
648
+ this._getThumbURL(coordinates).then(displayThumb);
649
+ }
650
+ // Click on a sequence
651
+ else {
652
+ this._getSequenceThumbURL(f.properties.id, new maplibregl.LngLat(...coordinates)).then(displayThumb);
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Get picture thumbnail URL at given coordinates
658
+ *
659
+ * @param {LngLat} coordinates The map coordinates
660
+ * @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
661
+ * @private
662
+ */
663
+ _getThumbURL(coordinates) {
664
+ return this._parent._api.getPicturesAroundCoordinates(coordinates[1], coordinates[0], 0.1, 1).then(res => {
665
+ const p = res?.features?.pop();
666
+ return p ? this._parent._api.findThumbnailInPictureFeature(p) : null;
667
+ });
668
+ }
669
+
670
+
671
+ /**
672
+ * Get picture thumbnail URL for a given sequence ID
673
+ *
674
+ * @param {string} seqId The sequence ID
675
+ * @param {LngLat} [coordinates] The map coordinates
676
+ * @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
677
+ * @private
678
+ */
679
+ _getSequenceThumbURL(seqId, coordinates) {
680
+ if(coordinates) {
681
+ return this._parent._api.getPicturesAroundCoordinates(coordinates.lat, coordinates.lng, 1, 1, seqId)
682
+ .then(results => {
683
+ if(results?.features?.length > 0) {
684
+ return this._parent._api.findThumbnailInPictureFeature(results.features[0]);
685
+ }
686
+ else {
687
+ return this._parent._api.getPictureThumbnailURLForSequence(seqId);
688
+ }
689
+ });
690
+ }
691
+ else {
692
+ return this._parent._api.getPictureThumbnailURLForSequence(seqId);
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Get picture thumbnail URL for a given picture ID.
698
+ * It handles a client-side cache based on raw API responses.
699
+ *
700
+ * @param {string} picId The picture ID
701
+ * @param {string} [seqId] The sequence ID (can speed up search if available)
702
+ * @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
703
+ *
704
+ * @private
705
+ */
706
+ _getPictureThumbURL(picId, seqId) {
707
+ let res = null;
708
+
709
+ if(picId) {
710
+ if(this._picThumbUrl[picId] !== undefined) {
711
+ res = typeof this._picThumbUrl[picId] === "string" ? Promise.resolve(this._picThumbUrl[picId]) : this._picThumbUrl[picId];
712
+ }
713
+ else {
714
+ this._picThumbUrl[picId] = this._parent._api.getPictureThumbnailURL(picId, seqId).then(url => {
715
+ if(url) {
716
+ this._picThumbUrl[picId] = url;
717
+ return url;
718
+ }
719
+ else {
720
+ this._picThumbUrl[picId] = null;
721
+ return null;
722
+ }
723
+ })
724
+ .catch(() => {
725
+ this._picThumbUrl[picId] = null;
726
+ });
727
+ res = this._picThumbUrl[picId];
728
+ }
729
+ }
730
+
731
+ return res;
732
+ }
733
+
734
+ /**
735
+ * Create a ready-to-use picture marker
736
+ *
737
+ * @returns {maplibregl.Marker} The generated marker
738
+ * @private
739
+ */
740
+ _getPictureMarker(selected = true) {
741
+ const img = document.createElement("img");
742
+ img.src = selected ? MarkerSelectedSVG : MarkerBaseSVG;
743
+ return new maplibregl.Marker({
744
+ element: img
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Event handler for sequence hover
750
+ * @private
751
+ * @param {object} e Event data
752
+ */
753
+ _onSequenceHover(e) {
754
+ e.preventDefault();
755
+ if(e.features.length > 0 && e.features[0].properties?.id) {
756
+ /**
757
+ * Event when a sequence on map is hovered (not selected)
758
+ *
759
+ * @event map:sequence-hover
760
+ * @memberof CoreView
761
+ * @type {object}
762
+ * @property {object} detail Event information
763
+ * @property {string} detail.seqId The hovered sequence ID
764
+ */
765
+ this._parent.dispatchEvent(new CustomEvent("map:sequence-hover", {
766
+ detail: {
767
+ seqId: e.features[0].properties.id
768
+ }
769
+ }));
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Event handler for sequence click
775
+ * @private
776
+ * @param {object} e Event data
777
+ */
778
+ _onSequenceClick(e) {
779
+ e.preventDefault();
780
+ if(e.features.length > 0 && e.features[0].properties?.id) {
781
+ /**
782
+ * Event when a sequence on map is clicked
783
+ *
784
+ * @event map:sequence-click
785
+ * @memberof CoreView
786
+ * @type {object}
787
+ * @property {object} detail Event information
788
+ * @property {string} detail.seqId The clicked sequence ID
789
+ * @property {maplibregl.LngLat} detail.coordinates The coordinates of user click
790
+ */
791
+ this._parent.dispatchEvent(new CustomEvent("map:sequence-click", {
792
+ detail: {
793
+ seqId: e.features[0].properties.id,
794
+ coordinates: e.lngLat
795
+ }
796
+ }));
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Event handler for picture click
802
+ * @private
803
+ * @param {object} e Event data
804
+ */
805
+ _onPictureClick(e) {
806
+ e.preventDefault();
807
+ const f = e?.features?.length > 0 ? e.features[0] : null;
808
+ if(f?.properties?.id) {
809
+ // Look for a potential sequence ID
810
+ let seqId = null;
811
+ try {
812
+ if(f.properties.sequences) {
813
+ if(!Array.isArray(f.properties.sequences)) { f.properties.sequences = JSON.parse(f.properties.sequences); }
814
+ seqId = f.properties.sequences.pop();
815
+ }
816
+ }
817
+ catch(e) {
818
+ console.log("Sequence ID is not available in vector tiles for picture "+f.properties.id);
819
+ }
820
+
821
+ /**
822
+ * Event when a picture on map is clicked
823
+ *
824
+ * @event map:picture-click
825
+ * @memberof CoreView
826
+ * @type {object}
827
+ * @property {object} detail Event information
828
+ * @property {string} detail.picId The clicked picture ID
829
+ * @property {string} detail.seqId The clicked picture's sequence ID
830
+ * @property {object} detail.feature The GeoJSON feature of the picture
831
+ */
832
+ this._parent.dispatchEvent(new CustomEvent("map:picture-click", {
833
+ detail: {
834
+ picId: f.properties.id,
835
+ seqId,
836
+ feature: f
837
+ }
838
+ }));
839
+ }
840
+ }
841
+ }