@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
|
@@ -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
|
+
}
|