@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.
- package/.dockerignore +6 -0
- package/.gitlab-ci.yml +71 -0
- package/CHANGELOG.md +428 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/Dockerfile +14 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/build/editor.html +1 -0
- package/build/index.css +36 -0
- package/build/index.css.map +1 -0
- package/build/index.html +1 -0
- package/build/index.js +25 -0
- package/build/index.js.map +1 -0
- package/build/map.html +1 -0
- package/build/viewer.html +1 -0
- package/config/env.js +104 -0
- package/config/getHttpsConfig.js +66 -0
- package/config/getPackageJson.js +25 -0
- package/config/jest/babelTransform.js +29 -0
- package/config/jest/cssTransform.js +14 -0
- package/config/jest/fileTransform.js +40 -0
- package/config/modules.js +134 -0
- package/config/paths.js +72 -0
- package/config/pnpTs.js +35 -0
- package/config/webpack/persistentCache/createEnvironmentHash.js +9 -0
- package/config/webpack.config.js +885 -0
- package/config/webpackDevServer.config.js +127 -0
- package/docs/01_Start.md +149 -0
- package/docs/02_Usage.md +828 -0
- package/docs/03_URL_settings.md +140 -0
- package/docs/04_Advanced_examples.md +214 -0
- package/docs/05_Compatibility.md +85 -0
- package/docs/09_Develop.md +62 -0
- package/docs/90_Releases.md +27 -0
- package/docs/images/class_diagram.drawio +129 -0
- package/docs/images/class_diagram.jpg +0 -0
- package/docs/images/screenshot.jpg +0 -0
- package/mkdocs.yml +45 -0
- package/package.json +254 -0
- package/public/editor.html +54 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +59 -0
- package/public/map.html +53 -0
- package/public/viewer.html +67 -0
- package/scripts/build.js +217 -0
- package/scripts/start.js +176 -0
- package/scripts/test.js +52 -0
- package/src/Editor.css +37 -0
- package/src/Editor.js +359 -0
- package/src/StandaloneMap.js +114 -0
- package/src/Viewer.css +203 -0
- package/src/Viewer.js +1186 -0
- package/src/components/CoreView.css +64 -0
- package/src/components/CoreView.js +159 -0
- package/src/components/Loader.css +56 -0
- package/src/components/Loader.js +111 -0
- package/src/components/Map.css +65 -0
- package/src/components/Map.js +841 -0
- package/src/components/Photo.css +36 -0
- package/src/components/Photo.js +687 -0
- package/src/img/arrow_360.svg +14 -0
- package/src/img/arrow_flat.svg +11 -0
- package/src/img/arrow_triangle.svg +10 -0
- package/src/img/arrow_turn.svg +9 -0
- package/src/img/bg_aerial.jpg +0 -0
- package/src/img/bg_streets.jpg +0 -0
- package/src/img/loader_base.jpg +0 -0
- package/src/img/loader_hd.jpg +0 -0
- package/src/img/logo_dead.svg +91 -0
- package/src/img/marker.svg +17 -0
- package/src/img/marker_blue.svg +20 -0
- package/src/img/switch_big.svg +44 -0
- package/src/img/switch_mini.svg +48 -0
- package/src/index.js +10 -0
- package/src/translations/de.json +163 -0
- package/src/translations/en.json +164 -0
- package/src/translations/eo.json +6 -0
- package/src/translations/es.json +164 -0
- package/src/translations/fi.json +1 -0
- package/src/translations/fr.json +164 -0
- package/src/translations/hu.json +133 -0
- package/src/translations/nl.json +1 -0
- package/src/translations/zh_Hant.json +136 -0
- package/src/utils/API.js +709 -0
- package/src/utils/Exif.js +198 -0
- package/src/utils/I18n.js +75 -0
- package/src/utils/Map.js +382 -0
- package/src/utils/PhotoAdapter.js +45 -0
- package/src/utils/Utils.js +568 -0
- package/src/utils/Widgets.js +477 -0
- package/src/viewer/URLHash.js +334 -0
- package/src/viewer/Widgets.css +711 -0
- package/src/viewer/Widgets.js +1196 -0
- package/tests/Editor.test.js +125 -0
- package/tests/StandaloneMap.test.js +44 -0
- package/tests/Viewer.test.js +363 -0
- package/tests/__snapshots__/Editor.test.js.snap +300 -0
- package/tests/__snapshots__/StandaloneMap.test.js.snap +30 -0
- package/tests/__snapshots__/Viewer.test.js.snap +195 -0
- package/tests/components/CoreView.test.js +91 -0
- package/tests/components/Loader.test.js +38 -0
- package/tests/components/Map.test.js +230 -0
- package/tests/components/Photo.test.js +335 -0
- package/tests/components/__snapshots__/Loader.test.js.snap +15 -0
- package/tests/components/__snapshots__/Map.test.js.snap +767 -0
- package/tests/components/__snapshots__/Photo.test.js.snap +205 -0
- package/tests/data/Map_geocoder_ban.json +36 -0
- package/tests/data/Map_geocoder_nominatim.json +56 -0
- package/tests/data/Viewer_pictures_1.json +148 -0
- package/tests/setupTests.js +5 -0
- package/tests/utils/API.test.js +906 -0
- package/tests/utils/Exif.test.js +124 -0
- package/tests/utils/I18n.test.js +28 -0
- package/tests/utils/Map.test.js +105 -0
- package/tests/utils/Utils.test.js +300 -0
- package/tests/utils/Widgets.test.js +107 -0
- package/tests/utils/__snapshots__/API.test.js.snap +132 -0
- package/tests/utils/__snapshots__/Exif.test.js.snap +43 -0
- package/tests/utils/__snapshots__/Map.test.js.snap +48 -0
- package/tests/utils/__snapshots__/Utils.test.js.snap +41 -0
- package/tests/utils/__snapshots__/Widgets.test.js.snap +44 -0
- package/tests/viewer/URLHash.test.js +537 -0
- package/tests/viewer/Widgets.test.js +127 -0
- package/tests/viewer/__snapshots__/URLHash.test.js.snap +98 -0
- package/tests/viewer/__snapshots__/Widgets.test.js.snap +393 -0
package/src/utils/API.js
ADDED
|
@@ -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
|
+
}
|