@panoramax/web-viewer 3.2.3-develop-d7e5a16d → 3.2.3-develop-6257391e

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 (221) hide show
  1. package/.gitlab-ci.yml +3 -0
  2. package/CHANGELOG.md +19 -0
  3. package/CODE_OF_CONDUCT.md +1 -1
  4. package/README.md +1 -1
  5. package/build/editor.html +10 -1
  6. package/build/index.css +2 -2
  7. package/build/index.css.map +1 -1
  8. package/build/index.html +1 -1
  9. package/build/index.js +1682 -5
  10. package/build/index.js.map +1 -1
  11. package/build/map.html +1 -1
  12. package/build/viewer.html +10 -1
  13. package/build/widgets.html +1 -0
  14. package/config/jest/mocks.js +172 -0
  15. package/config/paths.js +1 -0
  16. package/config/webpack.config.js +26 -0
  17. package/docs/03_URL_settings.md +3 -11
  18. package/docs/05_Compatibility.md +59 -76
  19. package/docs/09_Develop.md +30 -11
  20. package/docs/90_Releases.md +2 -2
  21. package/docs/images/class_diagram.drawio +28 -28
  22. package/docs/images/class_diagram.jpg +0 -0
  23. package/docs/index.md +112 -0
  24. package/docs/reference/components/core/Basic.md +153 -0
  25. package/docs/reference/components/core/CoverageMap.md +160 -0
  26. package/docs/reference/components/core/Editor.md +172 -0
  27. package/docs/reference/components/core/Viewer.md +288 -0
  28. package/docs/reference/components/layout/CorneredGrid.md +29 -0
  29. package/docs/reference/components/layout/Mini.md +45 -0
  30. package/docs/reference/components/menus/MapBackground.md +32 -0
  31. package/docs/reference/components/menus/MapFilters.md +15 -0
  32. package/docs/reference/components/menus/MapLayers.md +15 -0
  33. package/docs/reference/components/menus/MapLegend.md +15 -0
  34. package/docs/reference/components/menus/PictureLegend.md +15 -0
  35. package/docs/reference/components/menus/PictureMetadata.md +15 -0
  36. package/docs/reference/components/menus/PlayerOptions.md +15 -0
  37. package/docs/reference/components/menus/QualityScoreDoc.md +15 -0
  38. package/docs/reference/components/menus/ReportForm.md +15 -0
  39. package/docs/reference/components/menus/ShareMenu.md +15 -0
  40. package/docs/reference/components/ui/Button.md +39 -0
  41. package/docs/reference/components/ui/ButtonGroup.md +36 -0
  42. package/docs/reference/components/ui/CopyButton.md +35 -0
  43. package/docs/reference/components/ui/Grade.md +32 -0
  44. package/docs/reference/components/ui/LinkButton.md +44 -0
  45. package/docs/reference/components/ui/Loader.md +54 -0
  46. package/docs/reference/components/ui/Map.md +214 -0
  47. package/docs/reference/components/ui/MapMore.md +233 -0
  48. package/docs/reference/components/ui/Photo.md +369 -0
  49. package/docs/reference/components/ui/Popup.md +56 -0
  50. package/docs/reference/components/ui/QualityScore.md +45 -0
  51. package/docs/reference/components/ui/SearchBar.md +63 -0
  52. package/docs/reference/components/ui/TogglableGroup.md +39 -0
  53. package/docs/reference/components/ui/widgets/GeoSearch.md +32 -0
  54. package/docs/reference/components/ui/widgets/Legend.md +32 -0
  55. package/docs/reference/components/ui/widgets/MapFiltersButton.md +33 -0
  56. package/docs/reference/components/ui/widgets/MapLayersButton.md +15 -0
  57. package/docs/reference/components/ui/widgets/Player.md +32 -0
  58. package/docs/reference/components/ui/widgets/Share.md +15 -0
  59. package/docs/reference/components/ui/widgets/Zoom.md +15 -0
  60. package/docs/reference/utils/API.md +311 -0
  61. package/docs/reference/utils/InitParameters.md +67 -0
  62. package/docs/reference/utils/URLHandler.md +102 -0
  63. package/docs/reference.md +73 -0
  64. package/docs/shortcuts.md +11 -0
  65. package/docs/tutorials/aerial_imagery.md +19 -0
  66. package/docs/tutorials/authentication.md +10 -0
  67. package/docs/tutorials/custom_widgets.md +64 -0
  68. package/docs/tutorials/map_style.md +27 -0
  69. package/docs/tutorials/migrate_v4.md +122 -0
  70. package/docs/tutorials/synced_coverage.md +42 -0
  71. package/mkdocs.yml +60 -5
  72. package/package.json +10 -7
  73. package/public/editor.html +21 -29
  74. package/public/index.html +3 -3
  75. package/public/map.html +19 -18
  76. package/public/viewer.html +18 -24
  77. package/public/widgets.html +265 -0
  78. package/scripts/doc.js +77 -0
  79. package/src/components/core/Basic.css +44 -0
  80. package/src/components/core/Basic.js +258 -0
  81. package/src/components/core/CoverageMap.css +9 -0
  82. package/src/components/core/CoverageMap.js +105 -0
  83. package/src/components/core/Editor.css +23 -0
  84. package/src/components/core/Editor.js +354 -0
  85. package/src/components/core/Viewer.css +109 -0
  86. package/src/components/core/Viewer.js +707 -0
  87. package/src/components/core/index.js +11 -0
  88. package/src/components/index.js +13 -0
  89. package/src/components/layout/CorneredGrid.js +109 -0
  90. package/src/components/layout/Mini.js +117 -0
  91. package/src/components/layout/index.js +7 -0
  92. package/src/components/menus/MapBackground.js +106 -0
  93. package/src/components/menus/MapFilters.js +386 -0
  94. package/src/components/menus/MapLayers.js +143 -0
  95. package/src/components/menus/MapLegend.js +54 -0
  96. package/src/components/menus/PictureLegend.js +103 -0
  97. package/src/components/menus/PictureMetadata.js +188 -0
  98. package/src/components/menus/PlayerOptions.js +96 -0
  99. package/src/components/menus/QualityScoreDoc.js +36 -0
  100. package/src/components/menus/ReportForm.js +133 -0
  101. package/src/components/menus/Share.js +228 -0
  102. package/src/components/menus/index.js +15 -0
  103. package/src/components/styles.js +365 -0
  104. package/src/components/ui/Button.js +75 -0
  105. package/src/components/ui/ButtonGroup.css +49 -0
  106. package/src/components/ui/ButtonGroup.js +68 -0
  107. package/src/components/ui/CopyButton.js +71 -0
  108. package/src/components/ui/Grade.js +54 -0
  109. package/src/components/ui/LinkButton.js +68 -0
  110. package/src/components/ui/Loader.js +188 -0
  111. package/src/components/{Map.css → ui/Map.css} +5 -17
  112. package/src/components/{Map.js → ui/Map.js} +114 -138
  113. package/src/components/ui/MapMore.js +324 -0
  114. package/src/components/{Photo.css → ui/Photo.css} +6 -6
  115. package/src/components/{Photo.js → ui/Photo.js} +279 -90
  116. package/src/components/ui/Popup.js +145 -0
  117. package/src/components/ui/QualityScore.js +152 -0
  118. package/src/components/ui/SearchBar.js +363 -0
  119. package/src/components/ui/TogglableGroup.js +162 -0
  120. package/src/components/ui/index.js +20 -0
  121. package/src/components/ui/widgets/GeoSearch.css +21 -0
  122. package/src/components/ui/widgets/GeoSearch.js +139 -0
  123. package/src/components/ui/widgets/Legend.js +51 -0
  124. package/src/components/ui/widgets/MapFiltersButton.js +104 -0
  125. package/src/components/ui/widgets/MapLayersButton.js +79 -0
  126. package/src/components/ui/widgets/Player.css +7 -0
  127. package/src/components/ui/widgets/Player.js +148 -0
  128. package/src/components/ui/widgets/Share.js +30 -0
  129. package/src/components/ui/widgets/Zoom.js +82 -0
  130. package/src/components/ui/widgets/index.js +12 -0
  131. package/src/img/panoramax.svg +13 -0
  132. package/src/img/switch_big.svg +20 -10
  133. package/src/index.js +6 -9
  134. package/src/translations/da.json +1 -1
  135. package/src/translations/de.json +1 -1
  136. package/src/translations/en.json +5 -3
  137. package/src/translations/eo.json +1 -1
  138. package/src/translations/es.json +1 -1
  139. package/src/translations/fr.json +5 -3
  140. package/src/translations/hu.json +1 -1
  141. package/src/translations/it.json +1 -1
  142. package/src/translations/ja.json +1 -1
  143. package/src/translations/nl.json +1 -1
  144. package/src/translations/pl.json +1 -1
  145. package/src/translations/sv.json +1 -1
  146. package/src/translations/zh_Hant.json +1 -1
  147. package/src/utils/API.js +74 -42
  148. package/src/utils/InitParameters.js +354 -0
  149. package/src/utils/URLHandler.js +364 -0
  150. package/src/utils/geocoder.js +116 -0
  151. package/src/utils/{I18n.js → i18n.js} +3 -1
  152. package/src/utils/index.js +11 -0
  153. package/src/utils/{Map.js → map.js} +216 -80
  154. package/src/utils/picture.js +433 -0
  155. package/src/utils/utils.js +315 -0
  156. package/src/utils/widgets.js +93 -0
  157. package/tests/components/ui/CopyButton.test.js +52 -0
  158. package/tests/components/ui/Loader.test.js +54 -0
  159. package/tests/components/{Map.test.js → ui/Map.test.js} +19 -61
  160. package/tests/components/{Photo.test.js → ui/Photo.test.js} +89 -57
  161. package/tests/components/ui/Popup.test.js +24 -0
  162. package/tests/components/ui/QualityScore.test.js +17 -0
  163. package/tests/components/ui/SearchBar.test.js +107 -0
  164. package/tests/components/ui/__snapshots__/CopyButton.test.js.snap +34 -0
  165. package/tests/components/ui/__snapshots__/Loader.test.js.snap +56 -0
  166. package/tests/components/{__snapshots__ → ui/__snapshots__}/Map.test.js.snap +11 -38
  167. package/tests/components/{__snapshots__ → ui/__snapshots__}/Photo.test.js.snap +57 -4
  168. package/tests/components/ui/__snapshots__/Popup.test.js.snap +29 -0
  169. package/tests/components/ui/__snapshots__/QualityScore.test.js.snap +11 -0
  170. package/tests/components/ui/__snapshots__/SearchBar.test.js.snap +65 -0
  171. package/tests/utils/API.test.js +1 -14
  172. package/tests/utils/InitParameters.test.js +485 -0
  173. package/tests/utils/URLHandler.test.js +350 -0
  174. package/tests/utils/__snapshots__/URLHandler.test.js.snap +21 -0
  175. package/tests/utils/__snapshots__/picture.test.js.snap +315 -0
  176. package/tests/utils/__snapshots__/widgets.test.js.snap +19 -0
  177. package/tests/utils/geocoder.test.js +37 -0
  178. package/tests/utils/{I18n.test.js → i18n.test.js} +1 -1
  179. package/tests/utils/map.test.js +67 -0
  180. package/tests/utils/picture.test.js +745 -0
  181. package/tests/utils/utils.test.js +288 -0
  182. package/tests/utils/widgets.test.js +90 -0
  183. package/docs/01_Start.md +0 -149
  184. package/docs/02_Usage.md +0 -831
  185. package/docs/04_Advanced_examples.md +0 -216
  186. package/src/Editor.css +0 -37
  187. package/src/Editor.js +0 -361
  188. package/src/StandaloneMap.js +0 -114
  189. package/src/Viewer.css +0 -203
  190. package/src/Viewer.js +0 -1246
  191. package/src/components/CoreView.css +0 -70
  192. package/src/components/CoreView.js +0 -175
  193. package/src/components/Loader.css +0 -74
  194. package/src/components/Loader.js +0 -120
  195. package/src/utils/Exif.js +0 -193
  196. package/src/utils/Utils.js +0 -631
  197. package/src/utils/Widgets.js +0 -562
  198. package/src/viewer/URLHash.js +0 -469
  199. package/src/viewer/Widgets.css +0 -880
  200. package/src/viewer/Widgets.js +0 -1470
  201. package/tests/Editor.test.js +0 -126
  202. package/tests/StandaloneMap.test.js +0 -45
  203. package/tests/Viewer.test.js +0 -366
  204. package/tests/__snapshots__/Editor.test.js.snap +0 -298
  205. package/tests/__snapshots__/StandaloneMap.test.js.snap +0 -30
  206. package/tests/__snapshots__/Viewer.test.js.snap +0 -195
  207. package/tests/components/CoreView.test.js +0 -92
  208. package/tests/components/Loader.test.js +0 -38
  209. package/tests/components/__snapshots__/Loader.test.js.snap +0 -15
  210. package/tests/utils/Exif.test.js +0 -124
  211. package/tests/utils/Map.test.js +0 -113
  212. package/tests/utils/Utils.test.js +0 -300
  213. package/tests/utils/Widgets.test.js +0 -107
  214. package/tests/utils/__snapshots__/Exif.test.js.snap +0 -43
  215. package/tests/utils/__snapshots__/Utils.test.js.snap +0 -41
  216. package/tests/utils/__snapshots__/Widgets.test.js.snap +0 -44
  217. package/tests/viewer/URLHash.test.js +0 -559
  218. package/tests/viewer/Widgets.test.js +0 -127
  219. package/tests/viewer/__snapshots__/URLHash.test.js.snap +0 -108
  220. package/tests/viewer/__snapshots__/Widgets.test.js.snap +0 -403
  221. /package/tests/utils/__snapshots__/{Map.test.js.snap → geocoder.test.js.snap} +0 -0
@@ -0,0 +1,433 @@
1
+ import ArrowTriangleSVG from "../img/arrow_triangle.svg";
2
+ import ArrowTurnSVG from "../img/arrow_turn.svg";
3
+ import { svgToPSVLink, COLORS, getDistance, getSimplifiedAngle, getArrow } from "./utils";
4
+
5
+ const ArrowTriangle = svgToPSVLink(ArrowTriangleSVG, "white");
6
+ const ArrowTurn = svgToPSVLink(ArrowTurnSVG, COLORS.NEXT);
7
+
8
+ /**
9
+ * Read float value from EXIF tags (to handle fractions & all)
10
+ * @param {*} val The input EXIF tag value
11
+ * @returns {number|undefined} The parsed value, or undefined if value is not readable
12
+ * @private
13
+ */
14
+ export function getExifFloat(val) {
15
+ // Null-like values
16
+ if(
17
+ [null, undefined, ""].includes(val)
18
+ || typeof val === "string" && val.trim() === ""
19
+ ) {
20
+ return undefined;
21
+ }
22
+ // Already valid number
23
+ else if(typeof val === "number") {
24
+ return val;
25
+ }
26
+ // String
27
+ else if(typeof val === "string") {
28
+ // Check if looks like a fraction
29
+ if(/^-?\d+(\.\d+)?\/-?\d+(\.\d+)?$/.test(val)) {
30
+ const parts = val.split("/").map(p => parseFloat(p));
31
+ return parts[0] / parts[1];
32
+ }
33
+
34
+ // Try a direct cast to float
35
+ try { return parseFloat(val); }
36
+ catch(e) {} // eslint-disable-line no-empty
37
+
38
+ // Unrecognized
39
+ return undefined;
40
+ }
41
+ else { return undefined; }
42
+ }
43
+
44
+ /**
45
+ * Find in picture metadata the GPS precision.
46
+ * @param {object} picture The GeoJSON picture feature
47
+ * @returns {string} The precision value (poor, fair, moderate, good, excellent, ideal, unknown)
48
+ * @private
49
+ */
50
+ export function getGPSPrecision(picture) {
51
+ let quality = "❓";
52
+ const gpsHPosError = picture?.properties?.["quality:horizontal_accuracy"] || getExifFloat(picture?.properties?.exif?.["Exif.GPSInfo.GPSHPositioningError"]);
53
+ const gpsDop = getExifFloat(picture?.properties?.exif?.["Exif.GPSInfo.GPSDOP"]);
54
+
55
+ if(gpsHPosError !== undefined) {
56
+ quality = `${gpsHPosError} m`;
57
+ }
58
+ else if(gpsDop !== undefined) {
59
+ if(gpsDop < 1) { quality = "ideal"; }
60
+ else if(gpsDop < 2) { quality = "excellent"; }
61
+ else if(gpsDop < 5) { quality = "good"; }
62
+ else if(gpsDop < 10) { quality = "moderate"; }
63
+ else if(gpsDop < 20) { quality = "fair"; }
64
+ else { quality = "poor"; }
65
+ }
66
+
67
+ return quality;
68
+ }
69
+
70
+ /**
71
+ * Compute PSV sphere correction based on picture metadata & EXIF tags.
72
+ * @param {object} picture The GeoJSON picture feature
73
+ * @returns {object} The PSV sphereCorrection value
74
+ * @private
75
+ */
76
+ export function getSphereCorrection(picture) {
77
+ // Photo direction
78
+ let dir = picture.properties?.["view:azimuth"];
79
+ if(dir === undefined) {
80
+ const v = getExifFloat(picture.properties?.exif?.["Exif.GPSInfo.GPSImgDirection"]);
81
+ if(v !== undefined) {
82
+ dir = v;
83
+ }
84
+ }
85
+ dir = dir || 0;
86
+
87
+ // Yaw
88
+ let yaw = picture.properties?.["pers:yaw"];
89
+ let exifFallbacks = ["Xmp.GPano.PoseHeadingDegrees", "Xmp.Camera.Yaw", "Exif.MpfInfo.MPFYawAngle"];
90
+ if(yaw === undefined) {
91
+ for(let exif of exifFallbacks) {
92
+ const v = getExifFloat(picture.properties?.exif?.[exif]);
93
+ if(v !== undefined) {
94
+ yaw = v;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ yaw = yaw || 0;
100
+
101
+ // Check if yaw is applicable: different from photo direction
102
+ if(Math.round(dir) === Math.round(yaw) && yaw > 0) {
103
+ console.warn("Picture with UUID", picture.id, "has same GPS Image direction and Yaw, could cause rendering issues");
104
+ // yaw = 0;
105
+ }
106
+
107
+ // Pitch
108
+ let pitch = picture.properties?.["pers:pitch"];
109
+ exifFallbacks = ["Xmp.GPano.PosePitchDegrees", "Xmp.Camera.Pitch", "Exif.MpfInfo.MPFPitchAngle"];
110
+ if(pitch === undefined) {
111
+ for(let exif of exifFallbacks) {
112
+ const v = getExifFloat(picture.properties?.exif?.[exif]);
113
+ if(v !== undefined) {
114
+ pitch = v;
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ pitch = pitch || 0;
120
+
121
+ // Roll
122
+ let roll = picture.properties?.["pers:roll"];
123
+ exifFallbacks = ["Xmp.GPano.PoseRollDegrees", "Xmp.Camera.Roll", "Exif.MpfInfo.MPFRollAngle"];
124
+ if(roll === undefined) {
125
+ for(let exif of exifFallbacks) {
126
+ const v = getExifFloat(picture.properties?.exif?.[exif]);
127
+ if(v !== undefined) {
128
+ roll = v;
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ roll = roll || 0;
134
+
135
+ // Send result
136
+ return pitch !== 0 && roll !== 0 ? {
137
+ pan: yaw * Math.PI / 180,
138
+ tilt: pitch * Math.PI / 180,
139
+ roll: roll * Math.PI / 180,
140
+ } : {};
141
+ }
142
+
143
+ /**
144
+ * Compute PSV panoData for cropped panorama based on picture metadata & EXIF tags.
145
+ * @param {object} picture The GeoJSON picture feature
146
+ * @returns {object} The PSV panoData values
147
+ * @private
148
+ */
149
+ export function getCroppedPanoData(picture) {
150
+ let res;
151
+
152
+ if(picture.properties?.["pers:interior_orientation"]) {
153
+ if(
154
+ picture.properties["pers:interior_orientation"]?.["visible_area"]
155
+ && picture.properties["pers:interior_orientation"]?.["sensor_array_dimensions"]
156
+ ) {
157
+ const va = picture.properties["pers:interior_orientation"]["visible_area"];
158
+ const sad = picture.properties["pers:interior_orientation"]["sensor_array_dimensions"];
159
+ try {
160
+ res = {
161
+ fullWidth: parseInt(sad[0]),
162
+ fullHeight: parseInt(sad[1]),
163
+ croppedX: parseInt(va[0]),
164
+ croppedY: parseInt(va[1]),
165
+ croppedWidth: parseInt(sad[0]) - parseInt(va[2]) - parseInt(va[0]),
166
+ croppedHeight: parseInt(sad[1]) - parseInt(va[3]) - parseInt(va[1]),
167
+ };
168
+ }
169
+ catch(e) {
170
+ console.warn("Invalid pers:interior_orientation values for cropped panorama "+picture.id);
171
+ }
172
+ }
173
+ }
174
+
175
+ if(!res && picture.properties?.exif) {
176
+ try {
177
+ res = {
178
+ fullWidth: parseInt(picture.properties.exif?.["Xmp.GPano.FullPanoWidthPixels"]),
179
+ fullHeight: parseInt(picture.properties.exif?.["Xmp.GPano.FullPanoHeightPixels"]),
180
+ croppedX: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaLeftPixels"]),
181
+ croppedY: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaTopPixels"]),
182
+ croppedWidth: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaImageWidthPixels"]),
183
+ croppedHeight: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaImageHeightPixels"]),
184
+ };
185
+ }
186
+ catch(e) {
187
+ console.warn("Invalid XMP.GPano values for cropped panorama "+picture.id);
188
+ }
189
+ }
190
+
191
+ // Check if crop is really necessary
192
+ if(res) {
193
+ res = Object.fromEntries(Object.entries(res || {}).filter(e => !isNaN(e[1])));
194
+ if(res.fullWidth == res.croppedWidth && res.fullHeight == res.croppedHeight) {
195
+ res = {};
196
+ }
197
+ }
198
+
199
+ return res || {};
200
+ }
201
+
202
+ /**
203
+ * Compare function to retrieve most appropriate picture in a single direction.
204
+ *
205
+ * @param {number[]} picPos The picture [x,y] position
206
+ * @returns {function} A compare function for sorting
207
+ * @private
208
+ */
209
+ export function sortPicturesInDirection(picPos) {
210
+ return (a,b) => {
211
+ // Two prev/next links = no sort
212
+ if(a.rel != "related" && b.rel != "related") { return 0; }
213
+ // First is prev/next link = goes first
214
+ else if(a.rel != "related") { return -1; }
215
+ // Second is prev/next link = goes first
216
+ else if(b.rel != "related") { return 1; }
217
+ // Two related links same day = nearest goes first
218
+ else if(a.date == b.date) { return getDistance(picPos, a.geometry.coordinates) - getDistance(picPos, b.geometry.coordinates); }
219
+ // Two related links at different day = recent goes first
220
+ else { return b.date.localeCompare(a.date); }
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Generates the navbar caption based on a single picture metadata
226
+ *
227
+ * @param {object} metadata The picture metadata
228
+ * @param {object} t The labels translations container
229
+ * @returns {object} Normalized object with user name, licence and date
230
+ * @private
231
+ */
232
+ export function getNodeCaption(metadata, t) {
233
+ const caption = {};
234
+
235
+ // Timestamp
236
+ if(metadata?.properties?.datetimetz) {
237
+ caption.date = new Date(metadata.properties.datetimetz);
238
+ }
239
+ else if(metadata?.properties?.datetime) {
240
+ caption.date = new Date(metadata.properties.datetime);
241
+ }
242
+
243
+ // Producer
244
+ if(metadata?.providers) {
245
+ const producerRoles = metadata?.providers?.filter(el => el?.roles?.includes("producer"));
246
+ if(producerRoles?.length >= 0) {
247
+ // Avoid duplicates between account name and picture author
248
+ const producersDeduped = {};
249
+ producerRoles.map(p => p.name).forEach(p => {
250
+ const pmin = p.toLowerCase().replace(/\s/g, "");
251
+ if(producersDeduped[pmin]) { producersDeduped[pmin].push(p); }
252
+ else { producersDeduped[pmin] = [p];}
253
+ });
254
+
255
+ // Keep best looking name for each
256
+ caption.producer = [];
257
+ Object.values(producersDeduped).forEach(pv => {
258
+ const deflt = pv[0];
259
+ const better = pv.find(v => v.toLowerCase() != v);
260
+ caption.producer.push(better || deflt);
261
+ });
262
+ caption.producer = caption.producer.join(", ");
263
+ }
264
+ }
265
+
266
+ // License
267
+ if(metadata?.properties?.license) {
268
+ caption.license = metadata.properties.license;
269
+ // Look for URL to license
270
+ if(metadata?.links) {
271
+ const licenseLink = metadata.links.find(l => l?.rel === "license");
272
+ if(licenseLink) {
273
+ caption.license = `<a href="${licenseLink.href}" title="${t.pnx.metadata_general_license_link}" target="_blank">${caption.license}</a>`;
274
+ }
275
+ }
276
+ }
277
+
278
+ return caption;
279
+ }
280
+
281
+ /**
282
+ * Transforms a GeoJSON feature from the STAC API into a PSV node.
283
+ *
284
+ * @param {object} f The API GeoJSON feature
285
+ * @param {object} t The labels translations container
286
+ * @param {boolean} [fastInternet] True if Internet speed is high enough for loading HD flat pictures
287
+ * @param {function} [customLinkFilter] A function checking if a STAC link is acceptable to use for picture navigation
288
+ * @return {object} A PSV node
289
+ * @private
290
+ */
291
+ export function apiFeatureToPSVNode(f, t, fastInternet=false, customLinkFilter=null) {
292
+ const isHorizontalFovDefined = f.properties?.["pers:interior_orientation"]?.["field_of_view"] != null;
293
+ let horizontalFov = isHorizontalFovDefined ? parseInt(f.properties["pers:interior_orientation"]["field_of_view"]) : 70;
294
+ const is360 = horizontalFov === 360;
295
+
296
+ const hdUrl = (Object.values(f.assets).find(a => a?.roles?.includes("data")) || {}).href;
297
+ const matrix = f?.properties?.["tiles:tile_matrix_sets"]?.geovisio;
298
+ const prev = f.links.find(l => l?.rel === "prev" && l?.type === "application/geo+json");
299
+ const next = f.links.find(l => l?.rel === "next" && l?.type === "application/geo+json");
300
+ const baseUrlWebp = Object.values(f.assets).find(a => a.roles?.includes("visual") && a.type === "image/webp");
301
+ const baseUrlJpeg = Object.values(f.assets).find(a => a.roles?.includes("visual") && a.type === "image/jpeg");
302
+ const baseUrl = (baseUrlWebp || baseUrlJpeg).href;
303
+ const thumbUrl = (Object.values(f.assets).find(a => a.roles?.includes("thumbnail") && a.type === "image/jpeg"))?.href;
304
+ const tileUrl = f?.asset_templates?.tiles_webp || f?.asset_templates?.tiles;
305
+ const croppedPanoData = getCroppedPanoData(f);
306
+
307
+ let panorama;
308
+
309
+ // Cropped panorama
310
+ if(!tileUrl && Object.keys(croppedPanoData).length > 0) {
311
+ panorama = {
312
+ baseUrl: fastInternet ? hdUrl : baseUrl,
313
+ origBaseUrl: fastInternet ? hdUrl : baseUrl,
314
+ hdUrl,
315
+ thumbUrl,
316
+ basePanoData: croppedPanoData,
317
+ // This is only to mock loading of tiles (which are not available for flat pictures)
318
+ cols: 2, rows: 1, width: 2, tileUrl: () => null
319
+ };
320
+ }
321
+ // 360°
322
+ else if(is360 && matrix) {
323
+ panorama = {
324
+ baseUrl,
325
+ origBaseUrl: baseUrl,
326
+ basePanoData: (img) => ({
327
+ fullWidth: img.width,
328
+ fullHeight: img.height,
329
+ }),
330
+ hdUrl,
331
+ thumbUrl,
332
+ cols: matrix && matrix.tileMatrix[0].matrixWidth,
333
+ rows: matrix && matrix.tileMatrix[0].matrixHeight,
334
+ width: matrix && (matrix.tileMatrix[0].matrixWidth * matrix.tileMatrix[0].tileWidth),
335
+ tileUrl: matrix && ((col, row) => tileUrl.href.replace(/\{TileCol\}/g, col).replace(/\{TileRow\}/g, row))
336
+ };
337
+ }
338
+ // Flat pictures: shown only using a cropped base panorama
339
+ else {
340
+ panorama = {
341
+ baseUrl: fastInternet ? hdUrl : baseUrl,
342
+ origBaseUrl: fastInternet ? hdUrl : baseUrl,
343
+ hdUrl,
344
+ thumbUrl,
345
+ basePanoData: (img) => {
346
+ if (img.width < img.height && !isHorizontalFovDefined) {
347
+ horizontalFov = 35;
348
+ }
349
+ const verticalFov = horizontalFov * img.height / img.width;
350
+ const panoWidth = img.width * 360 / horizontalFov;
351
+ const panoHeight = img.height * 180 / verticalFov;
352
+
353
+ return {
354
+ fullWidth: panoWidth,
355
+ fullHeight: panoHeight,
356
+ croppedWidth: img.width,
357
+ croppedHeight: img.height,
358
+ croppedX: (panoWidth - img.width) / 2,
359
+ croppedY: (panoHeight - img.height) / 2,
360
+ };
361
+ },
362
+ // This is only to mock loading of tiles (which are not available for flat pictures)
363
+ cols: 2, rows: 1, width: 2, tileUrl: () => null
364
+ };
365
+ }
366
+
367
+ const node = {
368
+ id: f.id,
369
+ caption: getNodeCaption(f, t),
370
+ panorama,
371
+ links: filterRelatedPicsLinks(f, customLinkFilter),
372
+ gps: f.geometry.coordinates,
373
+ sequence: {
374
+ id: f.collection,
375
+ nextPic: next ? next.id : undefined,
376
+ prevPic: prev ? prev.id : undefined
377
+ },
378
+ sphereCorrection: getSphereCorrection(f),
379
+ horizontalFov,
380
+ properties: f.properties,
381
+ };
382
+
383
+ return node;
384
+ }
385
+
386
+ /**
387
+ * Filter surrounding pictures links to avoid too much arrows on viewer.
388
+ * @private
389
+ */
390
+ export function filterRelatedPicsLinks(metadata, customFilter = null) {
391
+ const picLinks = metadata.links
392
+ .filter(l => ["next", "prev", "related"].includes(l?.rel) && l?.type === "application/geo+json")
393
+ .filter(l => customFilter ? customFilter(l) : true)
394
+ .map(l => {
395
+ if(l.datetime) {
396
+ l.date = l.datetime.split("T")[0];
397
+ }
398
+ return l;
399
+ });
400
+ const picPos = metadata.geometry.coordinates;
401
+
402
+ // Filter to keep a single link per direction, in same sequence or most recent one
403
+ const filteredLinks = [];
404
+ const picSurroundings = { "N": [], "ENE": [], "ESE": [], "S": [], "WSW": [], "WNW": [] };
405
+
406
+ for(let picLink of picLinks) {
407
+ const a = getSimplifiedAngle(picPos, picLink.geometry.coordinates);
408
+ picSurroundings[a].push(picLink);
409
+ }
410
+
411
+ for(let direction in picSurroundings) {
412
+ const picsInDirection = picSurroundings[direction];
413
+ if(picsInDirection.length == 0) { continue; }
414
+ picsInDirection.sort(sortPicturesInDirection(picPos));
415
+ filteredLinks.push(picsInDirection.shift());
416
+ }
417
+
418
+ let arrowStyle = l => l.rel === "related" ? {
419
+ element: getArrow(ArrowTurn),
420
+ size: { width: 64*2/3, height: 192*2/3 }
421
+ } : {
422
+ element: getArrow(ArrowTriangle),
423
+ size: { width: 75, height: 75 }
424
+ };
425
+
426
+ const rectifiedYaw = - (metadata.properties?.["view:azimuth"] || 0) * (Math.PI / 180);
427
+ return filteredLinks.map(l => ({
428
+ nodeId: l.id,
429
+ gps: l.geometry.coordinates,
430
+ arrowStyle: arrowStyle(l),
431
+ linkOffset: { yaw: rectifiedYaw }
432
+ }));
433
+ }