@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,568 @@
1
+ import { getSphereCorrection, getCroppedPanoData } from "./Exif";
2
+
3
+ import ArrowTriangleSVG from "../img/arrow_triangle.svg";
4
+ import ArrowTurnSVG from "../img/arrow_turn.svg";
5
+
6
+ export const COLORS = {
7
+ BASE: "#FF6F00",
8
+ SELECTED: "#1E88E5",
9
+ HIDDEN: "#34495E",
10
+ NEXT: "#ffab40",
11
+
12
+ QUALI_1: "#00695C", // 360
13
+ QUALI_2: "#fd8d3c", // flat
14
+
15
+ PALETTE_1: "#fecc5c", // Oldest
16
+ PALETTE_2: "#fd8d3c",
17
+ PALETTE_3: "#f03b20",
18
+ PALETTE_4: "#bd0026" // Newest
19
+ };
20
+
21
+ export const COLORS_HEX = Object.fromEntries(Object.entries(COLORS).map(e => {
22
+ e[1] = parseInt(e[1].slice(1), 16);
23
+ return e;
24
+ }));
25
+
26
+ const ArrowTriangle = svgToPSVLink(ArrowTriangleSVG, "white");
27
+ const ArrowTurn = svgToPSVLink(ArrowTurnSVG, COLORS.NEXT);
28
+
29
+ /**
30
+ * Get cartesian distance between two points
31
+ * @param {number[]} from Start [x,y] coordinates
32
+ * @param {number[]} to End [x,y] coordinates
33
+ * @returns {number} The distance
34
+ * @private
35
+ */
36
+ export function getDistance(from, to) {
37
+ const dx = from[0] - to[0];
38
+ const dy = from[1] - to[1];
39
+ return Math.sqrt(dx*dx + dy*dy);
40
+ }
41
+
42
+ /**
43
+ * Compare function to retrieve most appropriate picture in a single direction.
44
+ *
45
+ * @param {number[]} picPos The picture [x,y] position
46
+ * @returns {function} A compare function for sorting
47
+ * @private
48
+ */
49
+ export function sortPicturesInDirection(picPos) {
50
+ return (a,b) => {
51
+ // Two prev/next links = no sort
52
+ if(a.rel != "related" && b.rel != "related") { return 0; }
53
+ // First is prev/next link = goes first
54
+ else if(a.rel != "related") { return -1; }
55
+ // Second is prev/next link = goes first
56
+ else if(b.rel != "related") { return 1; }
57
+ // Two related links same day = nearest goes first
58
+ else if(a.date == b.date) { return getDistance(picPos, a.geometry.coordinates) - getDistance(picPos, b.geometry.coordinates); }
59
+ // Two related links at different day = recent goes first
60
+ else { return b.date.localeCompare(a.date); }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Transforms a Base64 SVG string into a DOM img element.
66
+ * @param {string} svg The SVG as Base64 string
67
+ * @returns {Element} The DOM image element
68
+ * @private
69
+ */
70
+ function svgToPSVLink(svg, fillColor) {
71
+ try {
72
+ const svgStr = atob(svg.replace(/^data:image\/svg\+xml;base64,/, ""));
73
+ const svgXml = (new DOMParser()).parseFromString(svgStr, "image/svg+xml").childNodes[0];
74
+ const btn = document.createElement("button");
75
+ btn.appendChild(svgXml);
76
+ btn.classList.add("gvs-psv-tour-arrows");//"psv-virtual-tour-arrow", "psv-virtual-tour-link");
77
+ btn.style.color = fillColor;
78
+ return btn;
79
+ }
80
+ catch(e) {
81
+ const img = document.createElement("img");
82
+ img.src = svg;
83
+ return img;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Clones a model PSV link
89
+ * @private
90
+ */
91
+ function getArrow(a) {
92
+ const d = a.cloneNode(true);
93
+ d.addEventListener("pointerup", () => d.classList.add("gvs-clicked"));
94
+ return d;
95
+ }
96
+
97
+ /**
98
+ * Get direction based on angle
99
+ * @param {number[]} from Start [x,y] coordinates
100
+ * @param {number[]} to End [x,y] coordinates
101
+ * @returns {number} The azimuth, from 0 to 360°
102
+ * @private
103
+ */
104
+ export function getAzimuth(from, to) {
105
+ return (Math.atan2(to[0] - from[0], to[1] - from[1]) * (180 / Math.PI) + 360) % 360;
106
+ }
107
+
108
+ /**
109
+ * Computes relative heading for a single picture, based on its metadata
110
+ * @param {*} m The picture metadata
111
+ * @returns {number} The relative heading
112
+ * @private
113
+ */
114
+ export function getRelativeHeading(m) {
115
+ if(!m) { throw new Error("No picture selected"); }
116
+
117
+ let prevSegDir, nextSegDir;
118
+ const currHeading = m.properties["view:azimuth"];
119
+
120
+ // Previous picture GPS coordinates
121
+ if(m?.sequence?.prevPic) {
122
+ const prevLink = m?.links?.find(l => l.nodeId === m.sequence.prevPic);
123
+ if(prevLink) {
124
+ prevSegDir = (((currHeading - getAzimuth(prevLink.gps, m.gps)) + 180) % 360) - 180;
125
+ }
126
+ }
127
+
128
+ // Next picture GPS coordinates
129
+ if(m?.sequence?.nextPic) {
130
+ const nextLink = m?.links?.find(l => l.nodeId === m.sequence.nextPic);
131
+ if(nextLink) {
132
+ nextSegDir = (((currHeading - getAzimuth(m.gps, nextLink.gps)) + 180) % 360) - 180;
133
+ }
134
+ }
135
+
136
+ return prevSegDir !== undefined ? prevSegDir : (nextSegDir !== undefined ? nextSegDir : 0);
137
+ }
138
+
139
+ /**
140
+ * Get direction based on angle
141
+ * @param {number[]} from Start [x,y] coordinates
142
+ * @param {number[]} to End [x,y] coordinates
143
+ * @returns {string} Direction (N/ENE/ESE/S/WSW/WNW)
144
+ * @private
145
+ */
146
+ export function getSimplifiedAngle(from, to) {
147
+ const angle = Math.atan2(to[0] - from[0], to[1] - from[1]) * (180 / Math.PI); // -180 to 180°
148
+
149
+ // 6 directions version
150
+ if (Math.abs(angle) < 30) { return "N"; }
151
+ else if (angle >= 30 && angle < 90) { return "ENE"; }
152
+ else if (angle >= 90 && angle < 150) { return "ESE"; }
153
+ else if (Math.abs(angle) >= 150) { return "S"; }
154
+ else if (angle <= -30 && angle > -90) { return "WNW"; }
155
+ else if (angle <= -90 && angle > -150) { return "WSW"; }
156
+ }
157
+
158
+ /**
159
+ * Converts result from getPosition or position-updated event into x/y/z coordinates
160
+ *
161
+ * @param {object} pos pitch/yaw as given by PSV
162
+ * @param {number} zoom zoom as given by PSV
163
+ * @returns {object} Coordinates as x/y in degrees and zoom as given by PSV
164
+ * @private
165
+ */
166
+ export function positionToXYZ(pos, zoom = undefined) {
167
+ const res = {
168
+ x: pos.yaw * (180/Math.PI),
169
+ y: pos.pitch * (180/Math.PI)
170
+ };
171
+
172
+ if(zoom !== undefined) { res.z = zoom; }
173
+ return res;
174
+ }
175
+
176
+ /**
177
+ * Converts x/y/z coordinates into PSV position (lat/lon/zoom)
178
+ *
179
+ * @param {number} x The X coordinate (in degrees)
180
+ * @param {number} y The Y coordinate (in degrees)
181
+ * @param {number} z The zoom level (0-100)
182
+ * @returns {object} Position coordinates as yaw/pitch/zoom
183
+ * @private
184
+ */
185
+ export function xyzToPosition(x, y, z) {
186
+ return {
187
+ yaw: x / (180/Math.PI),
188
+ pitch: y / (180/Math.PI),
189
+ zoom: z
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Generates the navbar caption based on a single picture metadata
195
+ *
196
+ * @param {object} metadata The picture metadata
197
+ * @param {object} t The labels translations container
198
+ * @returns {object} Normalized object with user name, licence and date
199
+ * @private
200
+ */
201
+ export function getNodeCaption(metadata, t) {
202
+ const caption = {};
203
+
204
+ // Timestamp
205
+ if(metadata?.properties?.datetimetz) {
206
+ caption.date = new Date(metadata.properties.datetimetz);
207
+ }
208
+ else if(metadata?.properties?.datetime) {
209
+ caption.date = new Date(metadata.properties.datetime);
210
+ }
211
+
212
+ // Producer
213
+ if(metadata?.providers) {
214
+ const producerRoles = metadata?.providers?.filter(el => el?.roles?.includes("producer"));
215
+ if(producerRoles?.length >= 0) {
216
+ // Avoid duplicates between account name and picture author
217
+ const producersDeduped = {};
218
+ producerRoles.map(p => p.name).forEach(p => {
219
+ const pmin = p.toLowerCase().replace(/\s/g, "");
220
+ if(producersDeduped[pmin]) { producersDeduped[pmin].push(p); }
221
+ else { producersDeduped[pmin] = [p];}
222
+ });
223
+
224
+ // Keep best looking name for each
225
+ caption.producer = [];
226
+ Object.values(producersDeduped).forEach(pv => {
227
+ const deflt = pv[0];
228
+ const better = pv.find(v => v.toLowerCase() != v);
229
+ caption.producer.push(better || deflt);
230
+ });
231
+ caption.producer = caption.producer.join(", ");
232
+ }
233
+ }
234
+
235
+ // License
236
+ if(metadata?.properties?.license) {
237
+ caption.license = metadata.properties.license;
238
+ // Look for URL to license
239
+ if(metadata?.links) {
240
+ const licenseLink = metadata.links.find(l => l?.rel === "license");
241
+ if(licenseLink) {
242
+ caption.license = `<a href="${licenseLink.href}" title="${t.gvs.metadata_general_license_link}" target="_blank">${caption.license}</a>`;
243
+ }
244
+ }
245
+ }
246
+
247
+ return caption;
248
+ }
249
+
250
+ /**
251
+ * Creates links between map and photo elements.
252
+ * This enable interactions like click on map showing picture.
253
+ *
254
+ * @param {CoreView} parent The view containing both Photo and Map elements
255
+ * @private
256
+ */
257
+ export function linkMapAndPhoto(parent) {
258
+ // Switched picture
259
+ const onPicLoad = e => parent.map.displayPictureMarker(e.detail.lon, e.detail.lat, parent.psv.getXY().x);
260
+ parent.addEventListener("psv:picture-loading", onPicLoad);
261
+ parent.addEventListener("psv:picture-loaded", onPicLoad);
262
+
263
+ // Picture view rotated
264
+ parent.addEventListener("psv:view-rotated", () => {
265
+ let x = parent.psv.getPosition().yaw * (180 / Math.PI);
266
+ x += parent.psv.getPictureOriginalHeading();
267
+ parent.map._picMarker.setRotation(x);
268
+ });
269
+
270
+ // Picture preview
271
+ parent.addEventListener("psv:picture-preview-started", e => {
272
+ // Show marker corresponding to selection
273
+ parent.map._picMarkerPreview
274
+ .setLngLat(e.detail.coordinates)
275
+ .setRotation(e.detail.direction || 0)
276
+ .addTo(parent.map);
277
+ });
278
+
279
+ parent.addEventListener("psv:picture-preview-stopped", () => {
280
+ parent.map._picMarkerPreview.remove();
281
+ });
282
+
283
+ parent.addEventListener("psv:picture-loaded", e => {
284
+ if (parent.isWidthSmall() && parent._picPopup && e.detail.picId == parent._picPopup._picId) {
285
+ parent._picPopup.remove();
286
+ }
287
+ });
288
+
289
+ // Picture click
290
+ parent.addEventListener("map:picture-click", e => {
291
+ parent.select(e.detail.seqId, e.detail.picId);
292
+ if(!parent.psv._myVTour.state.currentNode && parent?.setFocus) { parent.setFocus("pic"); }
293
+ });
294
+
295
+ // Sequence click
296
+ parent.addEventListener("map:sequence-click", e => {
297
+ parent._api.getPicturesAroundCoordinates(
298
+ e.detail.coordinates.lat,
299
+ e.detail.coordinates.lng,
300
+ 1,
301
+ 1,
302
+ e.detail.seqId
303
+ ).then(results => {
304
+ if(results?.features?.length > 0) {
305
+ parent.select(results.features[0]?.collection, results.features[0].id);
306
+ if(!parent.psv.getPictureMetadata() && parent?.setFocus) { parent.setFocus("pic"); }
307
+ }
308
+ });
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Transforms a GeoJSON feature from the STAC API into a PSV node.
314
+ *
315
+ * @param {object} f The API GeoJSON feature
316
+ * @param {object} t The labels translations container
317
+ * @param {boolean} [fastInternet] True if Internet speed is high enough for loading HD flat pictures
318
+ * @param {function} [customLinkFilter] A function checking if a STAC link is acceptable to use for picture navigation
319
+ * @return {object} A PSV node
320
+ * @private
321
+ */
322
+ export function apiFeatureToPSVNode(f, t, fastInternet=false, customLinkFilter=null) {
323
+ const isHorizontalFovDefined = f.properties?.["pers:interior_orientation"]?.["field_of_view"] != null;
324
+ let horizontalFov = isHorizontalFovDefined ? parseInt(f.properties["pers:interior_orientation"]["field_of_view"]) : 70;
325
+ const is360 = horizontalFov === 360;
326
+
327
+ const hdUrl = (Object.values(f.assets).find(a => a?.roles?.includes("data")) || {}).href;
328
+ const matrix = f?.properties?.["tiles:tile_matrix_sets"]?.geovisio;
329
+ const prev = f.links.find(l => l?.rel === "prev" && l?.type === "application/geo+json");
330
+ const next = f.links.find(l => l?.rel === "next" && l?.type === "application/geo+json");
331
+ const baseUrlWebp = Object.values(f.assets).find(a => a.roles?.includes("visual") && a.type === "image/webp");
332
+ const baseUrlJpeg = Object.values(f.assets).find(a => a.roles?.includes("visual") && a.type === "image/jpeg");
333
+ const baseUrl = (baseUrlWebp || baseUrlJpeg).href;
334
+ const thumbUrl = (Object.values(f.assets).find(a => a.roles?.includes("thumbnail") && a.type === "image/jpeg"))?.href;
335
+ const tileUrl = f?.asset_templates?.tiles_webp || f?.asset_templates?.tiles;
336
+ const croppedPanoData = getCroppedPanoData(f);
337
+
338
+ let panorama;
339
+
340
+ // Cropped panorama
341
+ if(Object.keys(croppedPanoData).length > 0) {
342
+ panorama = {
343
+ baseUrl: fastInternet ? hdUrl : baseUrl,
344
+ origBaseUrl: fastInternet ? hdUrl : baseUrl,
345
+ hdUrl,
346
+ thumbUrl,
347
+ basePanoData: croppedPanoData,
348
+ // This is only to mock loading of tiles (which are not available for flat pictures)
349
+ cols: 2, rows: 1, width: 2, tileUrl: () => null
350
+ };
351
+ }
352
+ // 360°
353
+ else if(is360) {
354
+ panorama = {
355
+ baseUrl,
356
+ origBaseUrl: baseUrl,
357
+ basePanoData: (img) => ({
358
+ fullWidth: img.width,
359
+ fullHeight: img.height,
360
+ }),
361
+ hdUrl,
362
+ thumbUrl,
363
+ cols: matrix && matrix.tileMatrix[0].matrixWidth,
364
+ rows: matrix && matrix.tileMatrix[0].matrixHeight,
365
+ width: matrix && (matrix.tileMatrix[0].matrixWidth * matrix.tileMatrix[0].tileWidth),
366
+ tileUrl: matrix && ((col, row) => tileUrl.href.replace(/\{TileCol\}/g, col).replace(/\{TileRow\}/g, row))
367
+ };
368
+ }
369
+ // Flat pictures: shown only using a cropped base panorama
370
+ else {
371
+ panorama = {
372
+ baseUrl: fastInternet ? hdUrl : baseUrl,
373
+ origBaseUrl: fastInternet ? hdUrl : baseUrl,
374
+ hdUrl,
375
+ thumbUrl,
376
+ basePanoData: (img) => {
377
+ if (img.width < img.height && !isHorizontalFovDefined) {
378
+ horizontalFov = 35;
379
+ }
380
+ const verticalFov = horizontalFov * img.height / img.width;
381
+ const panoWidth = img.width * 360 / horizontalFov;
382
+ const panoHeight = img.height * 180 / verticalFov;
383
+
384
+ return {
385
+ fullWidth: panoWidth,
386
+ fullHeight: panoHeight,
387
+ croppedWidth: img.width,
388
+ croppedHeight: img.height,
389
+ croppedX: (panoWidth - img.width) / 2,
390
+ croppedY: (panoHeight - img.height) / 2,
391
+ };
392
+ },
393
+ // This is only to mock loading of tiles (which are not available for flat pictures)
394
+ cols: 2, rows: 1, width: 2, tileUrl: () => null
395
+ };
396
+ }
397
+
398
+ return {
399
+ id: f.id,
400
+ caption: getNodeCaption(f, t),
401
+ panorama,
402
+ links: filterRelatedPicsLinks(f, customLinkFilter),
403
+ gps: f.geometry.coordinates,
404
+ sequence: {
405
+ id: f.collection,
406
+ nextPic: next ? next.id : undefined,
407
+ prevPic: prev ? prev.id : undefined
408
+ },
409
+ sphereCorrection: getSphereCorrection(f),
410
+ horizontalFov,
411
+ properties: f.properties,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Filter surrounding pictures links to avoid too much arrows on viewer.
417
+ * @private
418
+ */
419
+ export function filterRelatedPicsLinks(metadata, customFilter = null) {
420
+ const picLinks = metadata.links
421
+ .filter(l => ["next", "prev", "related"].includes(l?.rel) && l?.type === "application/geo+json")
422
+ .filter(l => customFilter ? customFilter(l) : true)
423
+ .map(l => {
424
+ if(l.datetime) {
425
+ l.date = l.datetime.split("T")[0];
426
+ }
427
+ return l;
428
+ });
429
+ const picPos = metadata.geometry.coordinates;
430
+
431
+ // Filter to keep a single link per direction, in same sequence or most recent one
432
+ const filteredLinks = [];
433
+ const picSurroundings = { "N": [], "ENE": [], "ESE": [], "S": [], "WSW": [], "WNW": [] };
434
+
435
+ for(let picLink of picLinks) {
436
+ const a = getSimplifiedAngle(picPos, picLink.geometry.coordinates);
437
+ picSurroundings[a].push(picLink);
438
+ }
439
+
440
+ for(let direction in picSurroundings) {
441
+ const picsInDirection = picSurroundings[direction];
442
+ if(picsInDirection.length == 0) { continue; }
443
+ picsInDirection.sort(sortPicturesInDirection(picPos));
444
+ filteredLinks.push(picsInDirection.shift());
445
+ }
446
+
447
+ let arrowStyle = l => l.rel === "related" ? {
448
+ element: getArrow(ArrowTurn),
449
+ size: { width: 64*2/3, height: 192*2/3 }
450
+ } : {
451
+ element: getArrow(ArrowTriangle),
452
+ size: { width: 75, height: 75 }
453
+ };
454
+
455
+ const rectifiedYaw = - (metadata.properties?.["view:azimuth"] || 0) * (Math.PI / 180);
456
+ return filteredLinks.map(l => ({
457
+ nodeId: l.id,
458
+ gps: l.geometry.coordinates,
459
+ arrowStyle: arrowStyle(l),
460
+ linkOffset: { yaw: rectifiedYaw }
461
+ }));
462
+ }
463
+
464
+ /**
465
+ * Get the query string for JOSM to load current picture area
466
+ * @returns {string} The query string, or null if not available
467
+ * @private
468
+ */
469
+ export function josmBboxParameters(meta) {
470
+ if(meta) {
471
+ const coords = meta.gps;
472
+ const heading = meta?.properties?.["view:azimuth"];
473
+ const delta = 0.0002;
474
+ const values = {
475
+ left: coords[0] - (heading === null || heading >= 180 ? delta : 0),
476
+ right: coords[0] + (heading === null || heading <= 180 ? delta : 0),
477
+ top: coords[1] + (heading === null || heading <= 90 || heading >= 270 ? delta : 0),
478
+ bottom: coords[1] - (heading === null || (heading >= 90 && heading <= 270) ? delta : 0),
479
+ changeset_source: "Panoramax"
480
+ };
481
+ return Object.entries(values).map(e => e.join("=")).join("&");
482
+ }
483
+ else { return null; }
484
+ }
485
+
486
+ /**
487
+ * Check if code runs in an iframe or in a classic page.
488
+ * @returns {boolean} True if running in iframe
489
+ * @private
490
+ */
491
+ export function isInIframe() {
492
+ try {
493
+ return window.self !== window.top;
494
+ } catch(e) {
495
+ return true;
496
+ }
497
+ }
498
+
499
+
500
+ const INTERNET_FAST_THRESHOLD = 10; // MBit/s
501
+ const INTERNET_FAST_STORAGE = "gvs-internet-fast";
502
+ const INTERNET_FAST_TESTFILE = "https://panoramax.openstreetmap.fr/images/05/ca/2c/98/0111-4baf-b6f3-587bb8847d2e.jpg";
503
+
504
+ /**
505
+ * Check if Internet connection is high-speed or not.
506
+ * @returns {Promise} Resolves on true if high-speed.
507
+ * @private
508
+ */
509
+ export function isInternetFast() {
510
+ // Check if downlink property is available
511
+ try {
512
+ const speed = navigator.connection.downlink; // MBit/s
513
+ return Promise.resolve(speed >= INTERNET_FAST_THRESHOLD);
514
+ }
515
+ // Fallback for other browsers
516
+ catch(e) {
517
+ // Check if test has been done before and stored
518
+ const isFast = sessionStorage.getItem(INTERNET_FAST_STORAGE);
519
+ if(["true", "false"].includes(isFast)) {
520
+ return Promise.resolve(isFast === "true");
521
+ }
522
+
523
+ // Run download testing
524
+ const startTime = (new Date()).getTime();
525
+ return fetch(INTERNET_FAST_TESTFILE+"?nocache="+startTime)
526
+ .then(async res => [res, await res.blob()])
527
+ .then(([res, blob]) => {
528
+ const size = parseInt(res.headers.get("Content-Length") || blob.size); // Bytes
529
+ const endTime = (new Date()).getTime();
530
+ const duration = (endTime - startTime) / 1000; // Transfer time in seconds
531
+ const speed = (size * 8 / 1024 / 1024) / duration; // MBits/s
532
+ const isFast = speed >= INTERNET_FAST_THRESHOLD;
533
+ sessionStorage.setItem(INTERNET_FAST_STORAGE, isFast ? "true" : "false");
534
+ return isFast;
535
+ })
536
+ .catch(e => {
537
+ console.warn("Failed to run speedtest", e);
538
+ return false;
539
+ });
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Get a cookie value
545
+ * @param {str} name The cookie name
546
+ * @returns {str} The cookie value, or null if not found
547
+ */
548
+ export function getCookie(name) {
549
+ const parts = document.cookie
550
+ ?.split(";")
551
+ ?.find((row) => row.trimStart().startsWith(`${name}=`))
552
+ ?.split("=");
553
+ if(!parts) { return undefined; }
554
+ parts.shift();
555
+ return parts.join("=");
556
+ }
557
+
558
+ /**
559
+ * Checks if an user account exists
560
+ * @returns {object} Object like {"id", "name"} or null if no authenticated account
561
+ */
562
+ export function getUserAccount() {
563
+ const session = getCookie("session");
564
+ const user_id = getCookie("user_id");
565
+ const user_name = getCookie("user_name");
566
+
567
+ return (session && user_id && user_name) ? { id: user_id, name: user_name } : null;
568
+ }