@panoramax/web-viewer 3.1.0 → 3.1.1-develop-5214442e

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.
@@ -83,3 +83,17 @@ A supplementary layer _grid_ can be made available for low-zoom overview:
83
83
 
84
84
  - Available on zoom levels < 6
85
85
  - Available properties: `id` (grid cell ID), `nb_pictures` (amount of pictures), `coef` (value from 0 to 1, relative quantity of available pictures)
86
+
87
+ ### Labels translation
88
+
89
+ If your vector tiles support multiple languages, you can set in your `style.json` the list of supported languages :
90
+
91
+ ```json
92
+ {
93
+ "metadata": {
94
+ "panoramax:locales": ["fr", "en", "latin"]
95
+ }
96
+ }
97
+ ```
98
+
99
+ The viewer will try to find the best matching `name:LANG` property according to user browser settings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panoramax/web-viewer",
3
- "version": "3.1.0",
3
+ "version": "3.1.1-develop-5214442e",
4
4
  "description": "Panoramax web viewer for geolocated pictures",
5
5
  "main": "build/index.js",
6
6
  "author": "Panoramax team",
@@ -90,14 +90,14 @@
90
90
  "dependencies": {
91
91
  "@fortawesome/fontawesome-svg-core": "^6.4.0",
92
92
  "@fortawesome/free-solid-svg-icons": "^6.4.0",
93
- "@photo-sphere-viewer/core": "5.10.1",
94
- "@photo-sphere-viewer/equirectangular-tiles-adapter": "5.10.1",
95
- "@photo-sphere-viewer/gallery-plugin": "5.10.1",
96
- "@photo-sphere-viewer/markers-plugin": "5.10.1",
97
- "@photo-sphere-viewer/virtual-tour-plugin": "5.10.1",
93
+ "@photo-sphere-viewer/core": "5.11.0-beta.1",
94
+ "@photo-sphere-viewer/equirectangular-tiles-adapter": "5.11.0-beta.1",
95
+ "@photo-sphere-viewer/gallery-plugin": "5.11.0-beta.1",
96
+ "@photo-sphere-viewer/markers-plugin": "5.11.0-beta.1",
97
+ "@photo-sphere-viewer/virtual-tour-plugin": "5.11.0-beta.1",
98
98
  "documentation": "^14.0.1",
99
- "maplibre-gl": "^3.6.2",
100
- "pmtiles": "^2.11.0"
99
+ "maplibre-gl": "^4.7.1",
100
+ "pmtiles": "^3.2.0"
101
101
  },
102
102
  "eslintConfig": {
103
103
  "env": {
@@ -96,6 +96,13 @@ export default class Map extends maplibregl.Map {
96
96
  // Parent selection
97
97
  this._parent.addEventListener("select", this.reloadLayersStyles.bind(this));
98
98
 
99
+ // Timeout for initial loading
100
+ setTimeout(() => {
101
+ if(!this.loaded() && this._parent._loader.isVisible()) {
102
+ this._parent._loader.dismiss({}, this._parent._t.map.slow_loading, () => {});
103
+ }
104
+ }, 15000);
105
+
99
106
  this.on("load", async () => {
100
107
  await this.setVisibleUsers(this._parent._options.users);
101
108
  this.reloadLayersStyles();
@@ -291,10 +298,10 @@ export default class Map extends maplibregl.Map {
291
298
  filterUserLayersContent(dataType, filter) {
292
299
  [...this._userLayers].forEach(l => {
293
300
  this.setFilter(getUserLayerId(l, dataType), filter);
294
- if(dataType === "sequences") {
301
+ if(dataType === "sequences" && this.getLayer(getUserLayerId(l, "sequences_plus"))) {
295
302
  this.setFilter(getUserLayerId(l, "sequences_plus"), filter);
296
303
  }
297
- if(dataType === "pictures") {
304
+ if(dataType === "pictures" && this.getLayer(getUserLayerId(l, "pictures_symbols"))) {
298
305
  this.setFilter(getUserLayerId(l, "pictures_symbols"), filter);
299
306
  }
300
307
  });
@@ -362,7 +369,7 @@ export default class Map extends maplibregl.Map {
362
369
  Object.entries(style.sources).forEach(([sId, s]) => this.addSource(sId, s));
363
370
  style.layers = style.layers || [];
364
371
  const layers = style.layers.concat(getMissingLayerStyles(style.sources, style.layers));
365
- layers.forEach(l => this.addLayer(l, firstLabelLayerId?.id));
372
+ layers.filter(l => Object.keys(l).length > 0).forEach(l => this.addLayer(l, firstLabelLayerId?.id));
366
373
  }
367
374
 
368
375
  // Map interaction events
@@ -609,7 +616,7 @@ export default class Map extends maplibregl.Map {
609
616
  if(thumbUrl) {
610
617
  let content = document.createElement("img");
611
618
  content.classList.add("gvs-map-thumb");
612
- content.alt = this._parent._t.thumbnail;
619
+ content.alt = this._parent._t.map.thumbnail;
613
620
  let img = new Image();
614
621
  img.src = thumbUrl;
615
622
 
@@ -634,7 +641,7 @@ export default class Map extends maplibregl.Map {
634
641
  }
635
642
  }
636
643
  else {
637
- this._picPopup.setHTML(`<i>${this._parent._t.no_thumbnail}</i>`);
644
+ this._picPopup.setHTML(`<i>${this._parent._t.map.no_thumbnail}</i>`);
638
645
  }
639
646
  }
640
647
  };
@@ -194,7 +194,8 @@ export default class Photo extends PSViewer {
194
194
  rotation: following && sameSequence && animated,
195
195
  rotateTo: this.getPosition()
196
196
  };
197
- nodeTransition.rotateTo.yaw += fromNodeHeading - toNodeHeading;
197
+ // Constant direction related to North
198
+ // nodeTransition.rotateTo.yaw += fromNodeHeading - toNodeHeading;
198
199
  }
199
200
  }
200
201
  }
@@ -161,6 +161,7 @@
161
161
  "loading": "Loading…",
162
162
  "thumbnail": "Thumbnail of hovered picture",
163
163
  "no_thumbnail": "No thumbnail",
164
- "not_public": "Not publicly visible"
164
+ "not_public": "Not publicly visible",
165
+ "slow_loading": "Map is slow to load and could appear broken"
165
166
  }
166
167
  }
@@ -161,6 +161,7 @@
161
161
  "loading": "Chargement…",
162
162
  "thumbnail": "Miniature de la photo survolée",
163
163
  "no_thumbnail": "Pas de miniature",
164
- "not_public": "Masqué au public"
164
+ "not_public": "Masqué au public",
165
+ "slow_loading": "La carte est longue à charger et pourrait apparaître cassée"
165
166
  }
166
167
  }
@@ -120,7 +120,17 @@
120
120
  "show_psv": "顯示相片檢視器",
121
121
  "zoom": "縮放",
122
122
  "zoomIn": "拉近",
123
- "zoomOut": "拉遠"
123
+ "zoomOut": "拉遠",
124
+ "report_submit": "送出",
125
+ "report_email": "您的 email",
126
+ "report_nature_label": "問題的類型",
127
+ "report_nature": {
128
+ "": "選擇問題的類型…"
129
+ },
130
+ "share_embed_docs": "深入了解嵌入設定值",
131
+ "error_retry": "請稍後再試",
132
+ "error_click": "點擊以繼續",
133
+ "legend_title": "顯示相片的詳細資料"
124
134
  },
125
135
  "map": {
126
136
  "loading": "正在載入…",
@@ -132,5 +142,9 @@
132
142
  "ctrlZoom": "使用 CTRL + 滑鼠滾輪縮放圖片",
133
143
  "loadError": "無法載入全景圖",
134
144
  "twoFingers": "使用雙指移動"
145
+ },
146
+ "maplibre": {
147
+ "GeolocateControl.FindMyLocation": "尋找我的位置",
148
+ "GeolocateControl.LocationNotAvailable": "無法使用位置資訊"
135
149
  }
136
150
  }
package/src/utils/API.js CHANGED
@@ -285,7 +285,9 @@ export default class API {
285
285
  * @returns {object} The fetch options
286
286
  */
287
287
  _getFetchOptions() {
288
- return Object.assign({}, this._fetchOpts);
288
+ return Object.assign({
289
+ signal: AbortSignal.timeout(15000)
290
+ }, this._fetchOpts);
289
291
  }
290
292
 
291
293
  /**
@@ -295,8 +297,10 @@ export default class API {
295
297
  * @returns {function} The RequestTransformFunction
296
298
  */
297
299
  _getMapRequestTransform() {
300
+ const fetchOpts = this._getFetchOptions();
301
+ delete fetchOpts.signal;
298
302
  // Only if tiles endpoint is enabled and fetch options set
299
- if(Object.keys(this._getFetchOptions()).length > 0) {
303
+ if(Object.keys(fetchOpts).length > 0) {
300
304
  return (url) => {
301
305
  // As MapLibre will use this function for all its calls
302
306
  // We must make sure fetch options are sent only for
@@ -304,7 +308,7 @@ export default class API {
304
308
  if(url.startsWith(this._endpoint)) {
305
309
  return {
306
310
  url,
307
- ...this._getFetchOptions()
311
+ ...fetchOpts
308
312
  };
309
313
  }
310
314
  };
package/src/utils/I18n.js CHANGED
@@ -10,7 +10,13 @@ const TRANSLATIONS = {
10
10
  "de": T_de, "en": T_en, "es": T_es, "fr": T_fr, "hu": T_hu, "zh_Hant": T_zh_Hant,
11
11
  };
12
12
 
13
- const autoDetectLocale = () => {
13
+ /**
14
+ * Find best matching language regarding of list of supported languages and browser accepted languages
15
+ * @param {str[]} supportedTranslations List of supported languages
16
+ * @param {str} fallback The fallback language
17
+ * @returns The best matching language
18
+ */
19
+ export function autoDetectLocale(supportedTranslations, fallback) { // eslint-ignore import/no-unused-modules
14
20
  for (const navigatorLang of window.navigator.languages) {
15
21
  let language = navigatorLang;
16
22
  // Convert browser code to weblate code
@@ -30,13 +36,14 @@ const autoDetectLocale = () => {
30
36
  }
31
37
  break;
32
38
  }
33
- const pair = Object.entries(TRANSLATIONS).find((pair) => pair[0] === language);
39
+ const pair = supportedTranslations.find((pair) => pair === language);
34
40
  if (pair) {
35
- return pair[0];
41
+ return pair;
36
42
  }
37
43
  }
38
- return FALLBACK_LOCALE;
39
- };
44
+ return fallback;
45
+ }
46
+
40
47
  /**
41
48
  * Get text labels translations in given language
42
49
  *
@@ -50,7 +57,7 @@ export function getTranslations(lang = "") {
50
57
 
51
58
  // No specific lang set -> use browser lang
52
59
  if(!lang) {
53
- lang = autoDetectLocale();
60
+ lang = autoDetectLocale(Object.keys(TRANSLATIONS), FALLBACK_LOCALE);
54
61
  }
55
62
 
56
63
  // Lang exists -> send it
package/src/utils/Map.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import maplibregl from "!maplibre-gl";
3
3
  import LoaderImg from "../img/marker.svg";
4
4
  import { COLORS } from "./Utils";
5
+ import { autoDetectLocale } from "./I18n";
5
6
 
6
7
  export const DEFAULT_TILES = "https://panoramax.openstreetmap.fr/pmtiles/basic.json";
7
8
  export const RASTER_LAYER_ID = "gvs-aerial";
@@ -152,7 +153,47 @@ export function combineStyles(parent, options) {
152
153
  }
153
154
  }
154
155
  });
155
-
156
+
157
+ // TODO : remove override once available in default Panoramax style
158
+ if(!style.metadata["panoramax:locales"]) {
159
+ style.metadata["panoramax:locales"] = ["fr", "en", "de", "es", "ru", "pt", "zh", "hi", "latin"];
160
+ }
161
+
162
+ // Override labels to use appropriate language
163
+ if(style.metadata["panoramax:locales"]) {
164
+ const prefLang = autoDetectLocale(style.metadata["panoramax:locales"], "latin");
165
+ style.layers.forEach(l => {
166
+ if(isLabelLayer(l) && l.layout["text-field"].includes("name:latin")) {
167
+ l.layout["text-field"] = [
168
+ "coalesce",
169
+ ["get", `name:${prefLang}`],
170
+ ["get", "name:latin"],
171
+ ["get", "name"]
172
+ ];
173
+ }
174
+ });
175
+ }
176
+
177
+ // Fix for capital cities
178
+ const citiesLayer = style.layers.find(l => l.id == "place_label_city");
179
+ let capitalLayer = style.layers.find(l => l.id == "place_label_capital");
180
+ if(citiesLayer && !capitalLayer) {
181
+ // Create capital layer from original city style
182
+ capitalLayer = JSON.parse(JSON.stringify(citiesLayer));
183
+ capitalLayer.id = "place_label_capital";
184
+ capitalLayer.filter.push(["<=", "capital", 2]);
185
+
186
+ // Edit original city to make it less import
187
+ citiesLayer.filter.push([">", "capital", 2]);
188
+ citiesLayer.paint = {
189
+ "text-color": "hsl(0,0%,15%)",
190
+ "text-halo-blur": 0.5,
191
+ "text-halo-color": "hsl(0,0%,100%)",
192
+ "text-halo-width": 0.8,
193
+ };
194
+ style.layers.push(capitalLayer);
195
+ }
196
+
156
197
  return style;
157
198
  }
158
199
 
@@ -338,7 +338,7 @@ export function apiFeatureToPSVNode(f, t, fastInternet=false, customLinkFilter=n
338
338
  let panorama;
339
339
 
340
340
  // Cropped panorama
341
- if(Object.keys(croppedPanoData).length > 0) {
341
+ if(!tileUrl && Object.keys(croppedPanoData).length > 0) {
342
342
  panorama = {
343
343
  baseUrl: fastInternet ? hdUrl : baseUrl,
344
344
  origBaseUrl: fastInternet ? hdUrl : baseUrl,
@@ -57,11 +57,11 @@ export default class URLHash extends EventTarget {
57
57
  }
58
58
 
59
59
  /**
60
- * Get the hash string with current map/psv parameters
61
- * @return {string} The hash, starting with #
60
+ * Compute next hash parts
61
+ * @returns {object} Hash parameters
62
+ * @private
62
63
  */
63
- getHashString() {
64
- let hash = "";
64
+ _getHashParts() {
65
65
  let hashParts = {};
66
66
 
67
67
  if(typeof this._viewer.psv.getTransitionDuration() == "number") {
@@ -103,8 +103,17 @@ export default class URLHash extends EventTarget {
103
103
  else {
104
104
  hashParts.map = "none";
105
105
  }
106
+ return hashParts;
107
+ }
108
+
109
+ /**
110
+ * Get the hash string with current map/psv parameters
111
+ * @return {string} The hash, starting with #
112
+ */
113
+ getHashString() {
114
+ let hash = "";
106
115
 
107
- Object.entries(hashParts)
116
+ Object.entries(this._getHashParts())
108
117
  .sort((a,b) => a[0].localeCompare(b[0]))
109
118
  .forEach(entry => {
110
119
  let [ hashName, value ] = entry;
@@ -144,6 +153,63 @@ export default class URLHash extends EventTarget {
144
153
  .forEach(part => {
145
154
  keyvals[part[0]] = part[1];
146
155
  });
156
+
157
+ // If hash is compressed
158
+ if(keyvals.s) {
159
+ const shortVals = Object.fromEntries(
160
+ keyvals.s
161
+ .split("|")
162
+ .map(kv => [kv[0], kv.substring(1)])
163
+ );
164
+
165
+ keyvals = {};
166
+
167
+ // Used letters: b c d e f k m n p s t u v
168
+ // Focus
169
+ if(shortVals.f === "m") { keyvals.focus = "map"; }
170
+ else if(shortVals.f === "p") { keyvals.focus = "pic"; }
171
+ else if(shortVals.f === "t") { keyvals.focus = "meta"; }
172
+
173
+ // Speed
174
+ if(shortVals.s !== "") { keyvals.speed = parseFloat(shortVals.s) * 100; }
175
+
176
+ // Nav
177
+ if(shortVals.n === "a") { keyvals.nav = "any"; }
178
+ else if(shortVals.n === "s") { keyvals.nav = "seq"; }
179
+ if(shortVals.n === "n") { keyvals.nav = "none"; }
180
+
181
+ // Pic
182
+ if(shortVals.p !== "") { keyvals.pic = shortVals.p; }
183
+
184
+ // XYZ
185
+ if(shortVals.c !== "") { keyvals.xyz = shortVals.c; }
186
+
187
+ // Map
188
+ if(shortVals.m !== "") { keyvals.map = shortVals.m; }
189
+
190
+ // Date
191
+ if(shortVals.d !== "") { keyvals.date_from = shortVals.d; }
192
+ if(shortVals.e !== "") { keyvals.date_to = shortVals.e; }
193
+
194
+ // Pic type
195
+ if(shortVals.t === "f") { keyvals.pic_type = "flat"; }
196
+ else if(shortVals.t === "e") { keyvals.pic_type = "equirectangular"; }
197
+
198
+ // Camera
199
+ if(shortVals.k !== "") { keyvals.camera = shortVals.k; }
200
+
201
+ // Theme
202
+ if(shortVals.v === "d") { keyvals.theme = "default"; }
203
+ else if(shortVals.v === "a") { keyvals.theme = "age"; }
204
+ else if(shortVals.v === "t") { keyvals.theme = "type"; }
205
+
206
+ // Background
207
+ if(shortVals.b === "s") { keyvals.background = "streets"; }
208
+ else if(shortVals.b === "a") { keyvals.background = "aerial"; }
209
+
210
+ // Users
211
+ if(shortVals.u !== "") { keyvals.users = shortVals.u; }
212
+ }
147
213
 
148
214
  return keyvals;
149
215
  }
@@ -189,7 +255,7 @@ export default class URLHash extends EventTarget {
189
255
  * @private
190
256
  */
191
257
  _onHashChange() {
192
- const vals = this._getCurrentHash();
258
+ let vals = this._getCurrentHash();
193
259
 
194
260
  // Restore selected picture
195
261
  if(vals.pic) {
@@ -245,6 +311,36 @@ export default class URLHash extends EventTarget {
245
311
  }
246
312
  }
247
313
 
314
+ /**
315
+ * Get short link URL (hash replaced by Base64)
316
+ * @returns {str} The short link URL
317
+ */
318
+ getShortLink(baseUrl) {
319
+ const url = new URL(baseUrl);
320
+ const hashParts = this._getHashParts();
321
+ const shortVals = {
322
+ f: (hashParts.focus || "").substring(0, 1),
323
+ s: !isNaN(parseInt(hashParts.speed)) ? Math.floor(parseInt(hashParts.speed)/100) : undefined,
324
+ n: (hashParts.nav || "").substring(0, 1),
325
+ p: hashParts.pic,
326
+ c: hashParts.xyz,
327
+ m: hashParts.map,
328
+ d: hashParts.date_from,
329
+ e: hashParts.date_to,
330
+ t: (hashParts.pic_type || "").substring(0, 1),
331
+ k: hashParts.camera,
332
+ v: (hashParts.theme || "").substring(0, 1),
333
+ b: (hashParts.background || "").substring(0, 1),
334
+ u: hashParts.users,
335
+ };
336
+ const short = Object.entries(shortVals)
337
+ .filter(([,v]) => v != undefined && v != "")
338
+ .map(([k,v]) => `${k}${v}`)
339
+ .join("|");
340
+ url.hash = `s=${short}`;
341
+ return url;
342
+ }
343
+
248
344
  /**
249
345
  * Extracts from hash parsed keys all map filters values
250
346
  * @param {*} vals Hash keys
@@ -723,6 +723,8 @@ export default class Widgets {
723
723
  const overridenGeocoder = query => {
724
724
  const rgxCoords = /([-+]?\d{1,2}\.\d+),\s*([-+]?\d{1,3}\.\d+)/;
725
725
  const coordsMatch = query.match(rgxCoords);
726
+ const rgxUuid = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/;
727
+ const uuidMatch = query.match(rgxUuid);
726
728
 
727
729
  if(coordsMatch) {
728
730
  const lat = parseFloat(coordsMatch[1]);
@@ -732,7 +734,12 @@ export default class Widgets {
732
734
  zoom: 16,
733
735
  });
734
736
  return Promise.resolve(true);
735
- } else {
737
+ }
738
+ else if(uuidMatch) {
739
+ this._viewer.select(null, query);
740
+ return Promise.resolve(true);
741
+ }
742
+ else {
736
743
  return this._viewer.map.geocoder({
737
744
  query,
738
745
  limit: 3,
@@ -1172,7 +1179,7 @@ export default class Widgets {
1172
1179
  const btnId = pnlShare.querySelector("#gvs-edit-id");
1173
1180
  const btnRss = pnlShare.querySelector("#gvs-share-rss");
1174
1181
 
1175
- fUrl.setAttribute("data-copy", baseUrl);
1182
+ fUrl.setAttribute("data-copy", this._viewer?._hash?.getShortLink(baseUrl) || baseUrl);
1176
1183
  fIframe.innerText = `<iframe src="${iframeBaseUrl}" style="border: none; width: 500px; height: 300px"></iframe>`;
1177
1184
 
1178
1185
  const meta = this._viewer.psv.getPictureMetadata();
@@ -1192,6 +1199,7 @@ export default class Widgets {
1192
1199
  };
1193
1200
 
1194
1201
  updateLinks();
1202
+ this._viewer.addEventListener("ready", updateLinks, { once: true });
1195
1203
  this._viewer?._hash?.addEventListener("url-changed", updateLinks);
1196
1204
 
1197
1205
  // Copy to clipboard on button click
@@ -18,6 +18,7 @@ jest.mock("../src/components/Photo", () => class {
18
18
  });
19
19
 
20
20
  jest.mock("../src/components/Map", () => class {});
21
+ global.AbortSignal = { timeout: jest.fn() };
21
22
 
22
23
  const API_URL = "http://localhost:5000/api/search";
23
24
 
@@ -5,6 +5,7 @@ jest.mock("maplibre-gl", () => ({
5
5
  }));
6
6
 
7
7
  jest.mock("../src/components/Map", () => class {});
8
+ global.AbortSignal = { timeout: jest.fn() };
8
9
 
9
10
  const API_URL = "http://localhost:5000/api/search";
10
11
 
@@ -19,6 +19,8 @@ jest.mock("maplibre-gl", () => ({
19
19
  Map: class {},
20
20
  }));
21
21
 
22
+ global.AbortSignal = { timeout: jest.fn() };
23
+
22
24
  const API_URL = "http://localhost:5000/api/search";
23
25
 
24
26
 
@@ -7,6 +7,7 @@ jest.mock("maplibre-gl", () => ({
7
7
  }));
8
8
 
9
9
  global.console = { info: jest.fn() };
10
+ global.AbortSignal = { timeout: jest.fn() };
10
11
 
11
12
  describe("constructor", () => {
12
13
  it("works with JS element", () => {
@@ -26,6 +26,7 @@ jest.mock("maplibre-gl", () => ({
26
26
  return {
27
27
  layers: [],
28
28
  sources: {},
29
+ metadata: {},
29
30
  };
30
31
  }
31
32
  resize() {;}
@@ -52,7 +53,7 @@ const createParent = () => ({
52
53
  getDataBbox: jest.fn(),
53
54
  getPicturesTilesUrl: jest.fn(),
54
55
  _getMapRequestTransform: jest.fn(),
55
- getMapStyle: () => ({ sources: {}, layers: [] }),
56
+ getMapStyle: () => ({ sources: {}, layers: [], metadata: {} }),
56
57
  },
57
58
  _t: {
58
59
  maplibre: {},
@@ -205,6 +206,7 @@ describe("filterUserLayersContent", () => {
205
206
  const m = new Map(p, c);
206
207
  m.getSource = () => true;
207
208
  m.setPaintProperty = jest.fn();
209
+ m.getLayer = () => true;
208
210
  m._fire("load");
209
211
  m.setFilter = jest.fn();
210
212
  m.filterUserLayersContent("pictures", [["test", "true"]]);
@@ -3,6 +3,7 @@ import API from "../../src/utils/API";
3
3
  jest.mock("maplibre-gl", () => ({
4
4
  addProtocol: jest.fn(),
5
5
  }));
6
+ global.AbortSignal = { timeout: jest.fn() };
6
7
 
7
8
  const ENDPOINT = "https://panoramax.ign.fr/api";
8
9
  const VALID_LANDING = {
@@ -1,4 +1,87 @@
1
- import { getTranslations } from "../../src/utils/I18n";
1
+ import { autoDetectLocale, getTranslations } from "../../src/utils/I18n";
2
+
3
+ describe("autoDetectLocale", () => {
4
+ // Mock the window.navigator.languages
5
+ const originalNavigatorLanguages = window.navigator.languages;
6
+
7
+ afterEach(() => {
8
+ // Reset window.navigator.languages after each test
9
+ Object.defineProperty(window.navigator, "languages", {
10
+ value: originalNavigatorLanguages,
11
+ configurable: true
12
+ });
13
+ });
14
+
15
+ it("returns matched language from supportedTranslations", () => {
16
+ Object.defineProperty(window.navigator, "languages", {
17
+ value: ["fr-FR", "en-US"],
18
+ configurable: true
19
+ });
20
+ const supportedTranslations = ["en", "fr", "es"];
21
+ const fallback = "en";
22
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("fr");
23
+ });
24
+
25
+ it("returns fallback when no match is found", () => {
26
+ Object.defineProperty(window.navigator, "languages", {
27
+ value: ["de-DE", "it-IT"],
28
+ configurable: true
29
+ });
30
+ const supportedTranslations = ["en", "fr", "es"];
31
+ const fallback = "en";
32
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("en");
33
+ });
34
+
35
+ it("returns zh_Hant for Chinese Traditional locales", () => {
36
+ Object.defineProperty(window.navigator, "languages", {
37
+ value: ["zh-TW"],
38
+ configurable: true
39
+ });
40
+ const supportedTranslations = ["zh_Hant", "zh_Hans", "en"];
41
+ const fallback = "en";
42
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("zh_Hant");
43
+ });
44
+
45
+ it("returns zh_Hans for Chinese Simplified locales", () => {
46
+ Object.defineProperty(window.navigator, "languages", {
47
+ value: ["zh-CN"],
48
+ configurable: true
49
+ });
50
+ const supportedTranslations = ["zh_Hant", "zh_Hans", "en"];
51
+ const fallback = "en";
52
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("zh_Hans");
53
+ });
54
+
55
+ it("returns first matched language even when navigator language has region", () => {
56
+ Object.defineProperty(window.navigator, "languages", {
57
+ value: ["fr-CA", "en-US"],
58
+ configurable: true
59
+ });
60
+ const supportedTranslations = ["fr", "en"];
61
+ const fallback = "en";
62
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("fr");
63
+ });
64
+
65
+ it("returns fallback when supportedTranslations is empty", () => {
66
+ Object.defineProperty(window.navigator, "languages", {
67
+ value: ["fr-FR"],
68
+ configurable: true
69
+ });
70
+ const supportedTranslations = [];
71
+ const fallback = "en";
72
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("en");
73
+ });
74
+
75
+ it("handles language codes with more than two characters", () => {
76
+ Object.defineProperty(window.navigator, "languages", {
77
+ value: ["pt-BR", "en-US"],
78
+ configurable: true
79
+ });
80
+ const supportedTranslations = ["pt", "en"];
81
+ const fallback = "en";
82
+ expect(autoDetectLocale(supportedTranslations, fallback)).toBe("pt");
83
+ });
84
+ });
2
85
 
3
86
  describe("getTranslations", () => {
4
87
  it("works with default lang", () => {