@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,709 @@
1
+ import { TILES_PICTURES_ZOOM } from "./Map";
2
+
3
+ /**
4
+ * API contains various utility functions to communicate with the backend
5
+ *
6
+ * @param {string} endpoint The API endpoint. It corresponds to the <a href="https://github.com/radiantearth/stac-api-spec/blob/main/overview.md#example-landing-page">STAC landing page</a>, with all links describing the API capabilites.
7
+ * @param {object} [options] Options received from viewer that may change API behaviour
8
+ * @param {string|object} [options.style] General map style
9
+ * @param {string} [options.tiles] API route serving pictures & sequences vector tiles
10
+ * @param {boolean} [options.skipReadLanding] True to not call API landing page automatically (defaults to false)
11
+ * @param {object} [options.fetch] Set custom options for fetch calls made against API ([same syntax as fetch options parameter](https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters))
12
+ * @param {string[]} [options.users] List of initial user IDs to load map styles for.
13
+ * @private
14
+ */
15
+ export default class API {
16
+ constructor(endpoint, options = {}) {
17
+ if(endpoint === null || endpoint === undefined || typeof endpoint !== "string") {
18
+ throw new Error("endpoint parameter is empty or not a valid string");
19
+ }
20
+
21
+ // Parse local endpoints
22
+ if(endpoint.startsWith("/")) {
23
+ endpoint = window.location.href.split("/").slice(0, 3).join("/") + endpoint;
24
+ }
25
+
26
+ // Check endpoint
27
+ if(!API.isValidHttpUrl(endpoint)) {
28
+ throw new Error(`endpoint parameter is not a valid URL: ${endpoint}`);
29
+ }
30
+
31
+ this._endpoint = endpoint;
32
+ this._isReady = 0;
33
+ this._dataBbox = null;
34
+ this._fetchOpts = options?.fetch || {};
35
+ this._metadata = {};
36
+
37
+ if(options.skipReadLanding) { return; }
38
+ this._readLanding = fetch(endpoint, this._getFetchOptions())
39
+ .then(res => res.json())
40
+ .then(landing => this._parseLanding(landing, options))
41
+ .catch(e => {
42
+ this._isReady = -1;
43
+ console.error(e);
44
+ return Promise.reject("Viewer failed to communicate with API");
45
+ })
46
+ .then(() => this._loadMapStyles(options.style, options.users))
47
+ .then(() => {
48
+ this._isReady = 1;
49
+ return "API is ready";
50
+ });
51
+ }
52
+
53
+ /**
54
+ * This function resolves when API is ready to be used
55
+ *
56
+ * @returns {Promise} Resolves when API is ready
57
+ */
58
+ onceReady() {
59
+ if(this._isReady == -1) {
60
+ return Promise.reject("Viewer failed to communicate with API");
61
+ }
62
+ else if(this._isReady == 1) {
63
+ return Promise.resolve("API is ready");
64
+ }
65
+ else {
66
+ return this._readLanding;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Check if API is ready to be used
72
+ *
73
+ * @returns {boolean} True if ready
74
+ */
75
+ isReady() {
76
+ return this._isReady == 1;
77
+ }
78
+
79
+ /**
80
+ * Interprets JSON landing page and store important information
81
+ *
82
+ * @private
83
+ */
84
+ _parseLanding(landing, options) {
85
+ this._endpoints = {
86
+ "collections": null,
87
+ "search": null,
88
+ "style": null,
89
+ "user_style": null,
90
+ "tiles": options?.tiles || null,
91
+ "user_tiles": null,
92
+ "user_search": null,
93
+ "collection_preview": null,
94
+ "item_preview": null,
95
+ "rss": null,
96
+ "report": null,
97
+ };
98
+
99
+ if(!landing || !landing.links || !Array.isArray(landing.links)) {
100
+ throw new Error("API Landing page doesn't contain 'links' list");
101
+ }
102
+
103
+ if(!landing.stac_version.startsWith("1.0")) {
104
+ throw new Error(`API is not in a supported STAC version (Panoramax viewer supports only 1.0, API is ${landing.stac_version})`);
105
+ }
106
+
107
+ // Read metadata
108
+ this._metadata.name = landing.title || "Unnamed";
109
+ this._metadata.stac_version = landing.stac_version;
110
+ this._metadata.geovisio_version = landing.geovisio_version || "Unknown";
111
+
112
+ // Read links
113
+ const supportedLinks = [
114
+ {
115
+ rel: "search",
116
+ type: "application/geo+json",
117
+ endpointId: "search",
118
+ mandatory: true,
119
+ missingIssue: "No direct access to pictures metadata."
120
+ },
121
+ {
122
+ rel: "data",
123
+ type: "application/json",
124
+ endpointId: "collections",
125
+ mandatory: true,
126
+ missingIssue: "No way for viewer to access sequences."
127
+ },
128
+ {
129
+ rel: "data",
130
+ type: "application/rss+xml",
131
+ endpointId: "rss"
132
+ },
133
+ {
134
+ rel: "xyz",
135
+ type: "application/vnd.mapbox-vector-tile",
136
+ endpointId: "tiles"
137
+ },
138
+ {
139
+ rel: "xyz-style",
140
+ type: "application/json",
141
+ endpointId: "style"
142
+ },
143
+ {
144
+ rel: "user-xyz-style",
145
+ type: "application/json",
146
+ endpointId: "user_style"
147
+ },
148
+ {
149
+ rel: "user-xyz",
150
+ type: "application/vnd.mapbox-vector-tile",
151
+ endpointId: "user_tiles"
152
+ },
153
+ {
154
+ rel: "user-search",
155
+ type: "application/json",
156
+ endpointId: "user_search",
157
+ missingIssue: "Filter map data by user name will not be available."
158
+ },
159
+ {
160
+ rel: "collection-preview",
161
+ type: "image/jpeg",
162
+ endpointId: "collection_preview",
163
+ missingIssue: "Display of thumbnail could be slower."
164
+ },
165
+ {
166
+ rel: "item-preview",
167
+ type: "image/jpeg",
168
+ endpointId: "item_preview",
169
+ missingIssue: "Display of thumbnail could be slower."
170
+ },
171
+ {
172
+ rel: "report",
173
+ type: "application/json",
174
+ endpointId: "report"
175
+ }
176
+ ];
177
+
178
+ const blockingIssues = [];
179
+ const warningIssues = [];
180
+
181
+ supportedLinks.forEach(sl => {
182
+ // Find link in landing
183
+ const ll = landing.links.find(ll => ll.rel == sl.rel && ll.type == sl.type);
184
+
185
+ // No link found
186
+ if(!ll) {
187
+ if(!this._endpoints[sl.endpointId]) {
188
+ let label = `API doesn't offer a '${sl.rel}' (${sl.type}) endpoint in its links`;
189
+ if(sl.missingIssue) { label += `\n${sl.missingIssue}`; }
190
+
191
+ // Display issue (either blocking or not)
192
+ if(sl.mandatory) { blockingIssues.push(label); }
193
+ else if(sl.missingIssue) { warningIssues.push(label); }
194
+ }
195
+ }
196
+ // Link found
197
+ else {
198
+ // Invalid link
199
+ if(!API.isValidHttpUrl(ll.href)) {
200
+ throw new Error(`API endpoint '${ll.rel}' (${ll.type}) is not a valid URL: ${ll.href}`);
201
+ }
202
+
203
+ // Valid link -> stored in endpoints
204
+ if(!this._endpoints[sl.endpointId]) {
205
+ this._endpoints[sl.endpointId] = ll.href;
206
+ }
207
+ }
208
+ });
209
+
210
+ // Complex checks
211
+ if(!this._endpoints.style && !this._endpoints.tiles) {
212
+ warningIssues.push("API doesn't offer 'xyz' or 'xyz-style' endpoints in its links.\nMap widget will not be available.");
213
+ }
214
+ if(!this._endpoints.user_style && !this._endpoints.user_tiles) {
215
+ warningIssues.push("API doesn't offer 'user-xyz' or 'user-xyz-style' endpoints in its links.\nFilter map data by user ID will not be available.");
216
+ }
217
+
218
+ // Display warnings & errors
219
+ warningIssues.forEach(w => console.warn(w));
220
+ if(blockingIssues.length > 0) {
221
+ throw new Error(blockingIssues.join("\n"));
222
+ }
223
+
224
+ // Look for data BBox
225
+ const bbox = landing?.extent?.spatial?.bbox;
226
+ this._dataBbox = (
227
+ bbox &&
228
+ Array.isArray(bbox) &&
229
+ bbox.length > 0 &&
230
+ Array.isArray(bbox[0]) && bbox[0].length === 4
231
+ ) ?
232
+ [[bbox[0][0], bbox[0][1]], [bbox[0][2], bbox[0][3]]]
233
+ : null;
234
+ }
235
+
236
+ /**
237
+ * Loads all MapLibre Styles JSON needed at start.
238
+ * @param {string|object} style General map style
239
+ * @param {string[]} users List of user IDs to handle. Should include special user "geovisio" for general tiles loading.
240
+ * @returns {Promise} Resolves when style is ready.
241
+ */
242
+ _loadMapStyles(style, users) {
243
+ const mapUsers = new Set(users || []);
244
+
245
+ // Load all necessary map styles
246
+ this.mapStyle = { version: 8, sources: {}, layers: [] };
247
+ const stylePromises = [ this.getMapStyle() ];
248
+
249
+ // General map style
250
+ if(typeof style === "string") {
251
+ const fetchOpts = style.startsWith(this._endpoint) ? this._getFetchOptions() : undefined;
252
+ stylePromises.push(fetch(style, fetchOpts).then(res => res.json()));
253
+ }
254
+ else if(typeof style === "object") {
255
+ stylePromises.push(Promise.resolve(style));
256
+ }
257
+
258
+ // By-user style
259
+ [...mapUsers].filter(mu => mu !== "geovisio").forEach(mu => {
260
+ stylePromises.push(this.getUserMapStyle(mu, true));
261
+ });
262
+
263
+ return Promise.all(stylePromises)
264
+ .then(styles => {
265
+ const overridableProps = [
266
+ "bearing", "center", "glyphs", "light", "metadata", "name",
267
+ "pitch", "sky", "sprite", "terrain", "transition", "zoom"
268
+ ];
269
+
270
+ styles.forEach(style => {
271
+ overridableProps.forEach(p => {
272
+ if(style[p]) { this.mapStyle[p] = style[p] || this.mapStyle[p]; }
273
+ });
274
+ Object.assign(this.mapStyle.sources, style?.sources || {});
275
+ this.mapStyle.layers = this.mapStyle.layers.concat(style?.layers || []);
276
+ });
277
+ })
278
+ .catch(e => console.error(e));
279
+ }
280
+
281
+ /**
282
+ * Get the defaults fetch options to pass during API calls
283
+ *
284
+ * @private
285
+ * @returns {object} The fetch options
286
+ */
287
+ _getFetchOptions() {
288
+ return Object.assign({}, this._fetchOpts);
289
+ }
290
+
291
+ /**
292
+ * Get the RequestTransformFunction for MapLibre to handle fetch options
293
+ *
294
+ * @private
295
+ * @returns {function} The RequestTransformFunction
296
+ */
297
+ _getMapRequestTransform() {
298
+ // Only if tiles endpoint is enabled and fetch options set
299
+ if(Object.keys(this._getFetchOptions()).length > 0) {
300
+ return (url) => {
301
+ // As MapLibre will use this function for all its calls
302
+ // We must make sure fetch options are sent only for
303
+ // the STAC API calls, particularly the tiles endpoint
304
+ if(url.startsWith(this._endpoint)) {
305
+ return {
306
+ url,
307
+ ...this._getFetchOptions()
308
+ };
309
+ }
310
+ };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Get sequence GeoJSON representation
316
+ *
317
+ * @param {string} seqId The sequence ID
318
+ * @returns {Promise} Resolves on sequence GeoJSON
319
+ */
320
+ async getSequenceItems(seqId) {
321
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
322
+ try {
323
+ API.isIdValid(seqId);
324
+ return fetch(`${this._endpoints.collections}/${seqId}/items`, this._getFetchOptions()).then(res => res.json());
325
+ }
326
+ catch(e) {
327
+ return Promise.reject(e);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Get full URL for listing pictures around a specific location
333
+ *
334
+ * @param {number} lat Latitude
335
+ * @param {number} lon Longitude
336
+ * @param {number} [factor] The radius to search around (in degrees)
337
+ * @param {number} [limit] Max amount of pictures to retrieve
338
+ * @param {string} [seqId] The sequence ID to filter on (by default, no filter)
339
+ * @returns {string} The corresponding URL
340
+ */
341
+ getPicturesAroundCoordinatesUrl(lat, lon, factor = 0.0005, limit, seqId) {
342
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
343
+
344
+ if(isNaN(parseFloat(lat)) || isNaN(parseFloat(lon))) {
345
+ throw new Error("lat and lon parameters should be valid numbers");
346
+ }
347
+
348
+ const bbox = [ lon - factor, lat - factor, lon + factor, lat + factor ].map(d => d.toFixed(4)).join(",");
349
+ const lim = limit ? `&limit=${limit}` : "";
350
+ const seq = seqId ? `&collections=${seqId}`: "";
351
+ return `${this._endpoints.search}?bbox=${bbox}${lim}${seq}`;
352
+ }
353
+
354
+ /**
355
+ * Get list of pictures around a specific location
356
+ *
357
+ * @param {number} lat Latitude
358
+ * @param {number} lon Longitude
359
+ * @param {number} [factor] The radius to search around (in degrees)
360
+ * @param {number} [limit] Max amount of pictures to retrieve
361
+ * @param {string} [seqId] The sequence ID to filter on (by default, no filter)
362
+ * @returns {object} The GeoJSON feature collection
363
+ */
364
+ getPicturesAroundCoordinates(lat, lon, factor, limit, seqId) {
365
+ return fetch(this.getPicturesAroundCoordinatesUrl(lat, lon, factor, limit, seqId), this._getFetchOptions())
366
+ .then(res => res.json());
367
+ }
368
+
369
+ /**
370
+ * Get full URL for retrieving a specific picture metadata
371
+ *
372
+ * @param {string} picId The picture unique identifier
373
+ * @param {string} [seqId] The sequence ID
374
+ * @returns {string} The corresponding URL
375
+ */
376
+ getPictureMetadataUrl(picId, seqId) {
377
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
378
+
379
+ if(API.isIdValid(picId)) {
380
+ if(seqId) { return `${this._endpoints.collections}/${seqId}/items/${picId}`; }
381
+ else { return `${this._endpoints.search}?ids=${picId}`; }
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Get JSON style for general vector tiles
387
+ * @return {Promise} The MapLibre JSON style
388
+ * @fires Error If API is not ready, or no style defined.
389
+ */
390
+ getMapStyle() {
391
+ if(this.isReady()) { return this.mapStyle; }
392
+
393
+ let res;
394
+ // Directly available style
395
+ if(this._endpoints.style) {
396
+ res = this._endpoints.style;
397
+ }
398
+ // Vector tiles URL, embed in a minimal JSON style
399
+ else if(this._endpoints.tiles) {
400
+ res = {
401
+ "version": 8,
402
+ "sources": {
403
+ "geovisio": {
404
+ "type": "vector",
405
+ "tiles": [ this._endpoints.tiles ],
406
+ "minzoom": 0,
407
+ "maxzoom": TILES_PICTURES_ZOOM
408
+ }
409
+ }
410
+ };
411
+ }
412
+ // No endpoints : try fallback for GeoVisio API <= 2.0.1
413
+ else {
414
+ res = fetch(`${this._endpoint}/map/14/0/0.mvt`, this._getFetchOptions()).then(() => {
415
+ this._endpoints.tiles = `${this._endpoint}/map/{z}/{x}/{y}.mvt`;
416
+ console.log("Using fallback endpoint for vector tiles");
417
+ return this.getMapStyle();
418
+ }).catch(e => {
419
+ console.error(e);
420
+ return Promise.reject(new Error("API doesn't offer a vector tiles endpoint"));
421
+ });
422
+ }
423
+
424
+ // Call fetch if URL
425
+ if(typeof res === "string") {
426
+ return fetch(res, this._getFetchOptions()).then(res => res.json());
427
+ }
428
+ // Send JSON style directly
429
+ else {
430
+ return Promise.resolve(res);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Get JSON style for specific-user vector tiles
436
+ * @param {string} userId The user UUID
437
+ * @param {boolean} [skipReadyCheck=false] Skip check for API readyness
438
+ * @return {Promise} The MapLibre JSON style
439
+ * @fires Error If API is not ready, or no style defined.
440
+ */
441
+ getUserMapStyle(userId, skipReadyCheck = false) {
442
+ if(!skipReadyCheck && !this.isReady()) { return Promise.reject(new Error("API is not ready to use")); }
443
+ if(!userId) { return Promise.reject(new Error("Parameter userId is empty")); }
444
+
445
+ let res;
446
+ // Directly available style
447
+ if(this._endpoints.user_style) {
448
+ res = this._endpoints.user_style.replace(/\{userId\}/g, userId);
449
+ }
450
+ // Vector tiles URL, embed in a minimal JSON style
451
+ else if(this._endpoints.user_tiles) {
452
+ res = {
453
+ "version": 8,
454
+ "sources": {
455
+ [`geovisio_${userId}`]: {
456
+ "type": "vector",
457
+ "tiles": [ this._endpoints.user_tiles.replace(/\{userId\}/g, userId) ],
458
+ "minzoom": 0,
459
+ "maxzoom": TILES_PICTURES_ZOOM
460
+ }
461
+ }
462
+ };
463
+ }
464
+
465
+ if(!res) {
466
+ return Promise.reject(new Error("API doesn't offer map style for specific user"));
467
+ }
468
+ // Call fetch if URL
469
+ else if(typeof res === "string") {
470
+ return fetch(res, this._getFetchOptions()).then(res => res.json());
471
+ }
472
+ // Send JSON style directly
473
+ else {
474
+ return Promise.resolve(res);
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Find the thumbnail URL for a given picture
480
+ *
481
+ * @param {object} picture The picture GeoJSON feature
482
+ * @returns {string} The thumbnail URL, or null if not found
483
+ * @private
484
+ */
485
+ findThumbnailInPictureFeature(picture) {
486
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
487
+ if(!picture || !picture.assets) { return null; }
488
+
489
+ let visualFallback = null;
490
+ for(let a of Object.values(picture.assets)) {
491
+ if(a.roles.includes("thumbnail") && a.type == "image/jpeg" && API.isValidHttpUrl(a.href)) {
492
+ return a.href;
493
+ }
494
+ else if(a.roles.includes("visual") && a.type == "image/jpeg" && API.isValidHttpUrl(a.href)) {
495
+ visualFallback = a.href;
496
+ }
497
+ }
498
+ return visualFallback;
499
+ }
500
+
501
+ /**
502
+ * Get a picture thumbnail URL for a given sequence
503
+ *
504
+ * @param {string} seqId The sequence ID
505
+ * @param {object} [seq] The sequence metadata (with links) if already loaded
506
+ * @returns {Promise} Promise resolving on the picture thumbnail URL, or null if not found
507
+ */
508
+ getPictureThumbnailURLForSequence(seqId, seq) {
509
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
510
+
511
+ // Look for a dedicated endpoint in API
512
+ if(this._endpoints.collection_preview) {
513
+ return Promise.resolve(this._endpoints.collection_preview.replace("{id}", seqId));
514
+ }
515
+
516
+ // Check if a preview link exists in sequence metadata
517
+ if(seq && Array.isArray(seq.links) && seq.links.length > 0) {
518
+ let preview = seq.links.find(l => l.rel === "preview" && l.type === "image/jpeg");
519
+ if(preview && API.isValidHttpUrl(preview.href)) {
520
+ return Promise.resolve(preview.href);
521
+ }
522
+ }
523
+
524
+ // Otherwise, search for a single picture in collection
525
+ const url = `${this._endpoints.search}?limit=1&collections=${seqId}`;
526
+
527
+ return fetch(url, this._getFetchOptions())
528
+ .then(res => res.json())
529
+ .then(res => {
530
+ if(!Array.isArray(res.features) || res.features.length == 0) {
531
+ return null;
532
+ }
533
+
534
+ return this.findThumbnailInPictureFeature(res.features.pop());
535
+ });
536
+ }
537
+
538
+ /**
539
+ * Get thumbnail URL for a specific picture
540
+ *
541
+ * @param {string} picId The picture unique identifier
542
+ * @param {string} [seqId] The sequence ID
543
+ * @returns {Promise} The corresponding URL on resolve, or undefined if no thumbnail could be found
544
+ */
545
+ getPictureThumbnailURL(picId, seqId) {
546
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
547
+
548
+ if(!picId) { return Promise.resolve(null); }
549
+
550
+ // Look for a dedicated endpoint in API
551
+ if(this._endpoints.item_preview) {
552
+ return Promise.resolve(this._endpoints.item_preview.replace("{id}", picId));
553
+ }
554
+
555
+ // Pic + sequence IDs defined -> use direct item metadata
556
+ if(picId && seqId) {
557
+ return fetch(`${this._endpoints.collections}/${seqId}/items/${picId}`, this._getFetchOptions())
558
+ .then(res => res.json())
559
+ .then(picture => {
560
+ return picture ? this.findThumbnailInPictureFeature(picture) : null;
561
+ });
562
+ }
563
+
564
+ // Picture ID only -> use search as fallback
565
+ return fetch(`${this._endpoints.search}?ids=${picId}`, this._getFetchOptions())
566
+ .then(res => res.json())
567
+ .then(res => {
568
+ if(!res || !Array.isArray(res.features) || res.features.length == 0) { return null; }
569
+ return this.findThumbnailInPictureFeature(res.features.pop());
570
+ });
571
+ }
572
+
573
+ /**
574
+ * Get the RSS feed URL with map parameters (if map is enabled)
575
+ *
576
+ * @param {LngLatBounds} [bbox] The map current bounding box, or null if not available
577
+ * @returns {string} The URL, or null if no RSS feed is available
578
+ */
579
+ getRSSURL(bbox) {
580
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
581
+
582
+ if(this._endpoints.rss) {
583
+ let url = this._endpoints.rss;
584
+ if(bbox) {
585
+ url += url.includes("?") ? "&": "?";
586
+ url += "bbox=" + [bbox.getWest(), bbox.getSouth(), bbox.getEast(), bbox.getNorth()].join(",");
587
+ }
588
+ return url;
589
+ }
590
+ else {
591
+ return null;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Get full URL for retrieving a specific sequence metadata
597
+ *
598
+ * @param {string} seqId The sequence ID
599
+ * @returns {string} The corresponding URL
600
+ */
601
+ getSequenceMetadataUrl(seqId) {
602
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
603
+ return `${this._endpoints.collections}/${seqId}`;
604
+ }
605
+
606
+ /**
607
+ * Get available data bounding box
608
+ *
609
+ * @returns {LngLatBoundsLike} The bounding box or null if not available
610
+ */
611
+ getDataBbox() {
612
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
613
+
614
+ return this._dataBbox;
615
+ }
616
+
617
+ /**
618
+ * Look for user ID based on user name query
619
+ * @param {string} query The user name to look for
620
+ * @returns {Promise} Resolves on list of potential users
621
+ */
622
+ searchUsers(query) {
623
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
624
+ if(!this._endpoints.user_search) { throw new Error("User search is not available"); }
625
+
626
+ return fetch(`${this._endpoints.user_search}?q=${query}`, this._getFetchOptions())
627
+ .then(res => res.json())
628
+ .then(res => {
629
+ return res?.features || null;
630
+ });
631
+ }
632
+
633
+ /**
634
+ * Get user name based on its ID
635
+ * @param {string} userId The user UUID
636
+ * @returns {Promise} Resolves on user name
637
+ */
638
+ getUserName(userId) {
639
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
640
+ if(!this._endpoints.user_search) { throw new Error("User search is not available"); }
641
+
642
+ return fetch(this._endpoints.user_search.replace(/\/search$/, `/${userId}`), this._getFetchOptions())
643
+ .then(res => res.json())
644
+ .then(res => {
645
+ return res?.name || null;
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Send a report to API
651
+ * @param {object} data The input form data
652
+ * @returns {Promise} Resolves on report sent
653
+ */
654
+ sendReport(data) {
655
+ if(!this.isReady()) { throw new Error("API is not ready to use"); }
656
+ if(!this._endpoints.report) { throw new Error("Report sending is not available"); }
657
+
658
+ const opts = {
659
+ ...this._getFetchOptions(),
660
+ method: "POST",
661
+ body: JSON.stringify(data),
662
+ headers: { "Content-Type": "application/json" },
663
+ };
664
+ return fetch(this._endpoints.report, opts)
665
+ .then(async res => {
666
+ if(res.status >= 400) {
667
+ let txt = await res.text();
668
+ try {
669
+ txt = JSON.parse(txt)["message"];
670
+ }
671
+ catch(e) {} // eslint-disable-line no-empty
672
+ return Promise.reject(txt);
673
+ }
674
+ return res.json();
675
+ });
676
+ }
677
+
678
+ /**
679
+ * Checks URL string validity
680
+ *
681
+ * @param {string} str The URL to check
682
+ * @returns {boolean} True if valid
683
+ */
684
+ static isValidHttpUrl(str) {
685
+ let url;
686
+
687
+ try {
688
+ url = new URL(str);
689
+ } catch (_) {
690
+ return false;
691
+ }
692
+
693
+ return url.protocol === "http:" || url.protocol === "https:";
694
+ }
695
+
696
+ /**
697
+ * Checks picture or sequence ID validity
698
+ *
699
+ * @param {string} id The ID to check
700
+ * @returns {boolean} True if valid
701
+ * @throws {Error} If not valid
702
+ */
703
+ static isIdValid(id) {
704
+ if(!id || typeof id !== "string" || id.length === 0) {
705
+ throw new Error("id should be a valid picture unique identifier");
706
+ }
707
+ return true;
708
+ }
709
+ }