@panoramax/web-viewer 4.0.2-develop-9b499e28 → 4.0.2-develop-e389d775

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 (45) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/build/index.css +1 -1
  3. package/build/index.css.map +1 -1
  4. package/build/index.js +274 -59
  5. package/build/index.js.map +1 -1
  6. package/config/jest/mocks.js +5 -0
  7. package/docs/reference/components/core/PhotoViewer.md +1 -0
  8. package/docs/reference/components/core/Viewer.md +1 -0
  9. package/docs/reference/components/menus/AnnotationsList.md +16 -0
  10. package/docs/reference/components/ui/HashTags.md +15 -0
  11. package/docs/reference/components/ui/ListItem.md +38 -0
  12. package/docs/reference/components/ui/Photo.md +53 -1
  13. package/docs/reference/components/ui/SemanticsTable.md +32 -0
  14. package/docs/reference/utils/PresetsManager.md +35 -0
  15. package/docs/reference.md +4 -0
  16. package/mkdocs.yml +4 -0
  17. package/package.json +2 -1
  18. package/src/components/core/Basic.css +2 -0
  19. package/src/components/core/PhotoViewer.js +11 -0
  20. package/src/components/core/Viewer.js +7 -0
  21. package/src/components/layout/Tabs.js +1 -1
  22. package/src/components/menus/AnnotationsList.js +146 -0
  23. package/src/components/menus/PictureLegend.js +2 -4
  24. package/src/components/menus/PictureMetadata.js +66 -2
  25. package/src/components/menus/index.js +1 -0
  26. package/src/components/styles.js +34 -0
  27. package/src/components/ui/HashTags.js +98 -0
  28. package/src/components/ui/ListItem.js +83 -0
  29. package/src/components/ui/Photo.js +137 -0
  30. package/src/components/ui/SemanticsTable.js +87 -0
  31. package/src/components/ui/index.js +3 -0
  32. package/src/img/osm.svg +49 -0
  33. package/src/img/wd.svg +1 -0
  34. package/src/translations/en.json +22 -0
  35. package/src/translations/fr.json +20 -0
  36. package/src/utils/PresetsManager.js +137 -0
  37. package/src/utils/index.js +3 -1
  38. package/src/utils/picture.js +28 -0
  39. package/src/utils/semantics.js +162 -0
  40. package/src/utils/services.js +38 -0
  41. package/src/utils/widgets.js +18 -1
  42. package/tests/components/core/__snapshots__/PhotoViewer.test.js.snap +10 -0
  43. package/tests/components/core/__snapshots__/Viewer.test.js.snap +10 -0
  44. package/tests/utils/PresetsManager.test.js +123 -0
  45. package/tests/utils/semantics.test.js +125 -0
@@ -173,6 +173,28 @@
173
173
  "metadata_exif_value": "Value",
174
174
  "metadata_exif_doc": "Docs for EXIF tags",
175
175
  "metadata_exif_doc_title": "Go to Exiv2 documentation to have full details on EXIF and XMP tags definitions",
176
+ "semantics_title": "Tags",
177
+ "semantics_tags": "{nb} tags",
178
+ "semantics_features": "Detected features",
179
+ "semantics_features_none": "No feature has been detected or annotated in this picture yet.",
180
+ "semantics_features_default_title": "Feature #{nb}",
181
+ "semantics_features_subtitle": "{nb} associated properties",
182
+ "semantics_show_all_tags": "Show all tags",
183
+ "semantics_hide_all_tags": "Hide all tags",
184
+ "semantics_key": "Key",
185
+ "semantics_value": "Value",
186
+ "semantics_key_doc": "Know more about this key",
187
+ "semantics_value_doc": "Know more about this tag",
188
+ "semantics_tags_picture": "Picture tags",
189
+ "semantics_tags_picture_none": "No tags has been set for this picture yet.",
190
+ "semantics_qualifiers": "Qualifiers",
191
+ "semantics_show_annotations": "Display picture annotations",
192
+ "semantics_hide_annotations": "Hide picture annotations",
193
+ "semantics_wikidata_properties": {
194
+ "P31": "instance of",
195
+ "P279": "subclass of",
196
+ "P361": "part of"
197
+ },
176
198
  "report": "Report",
177
199
  "report_auth": "This report will be sent using your account \"{a}\"",
178
200
  "report_nature_label": "Nature of the issue",
@@ -173,6 +173,26 @@
173
173
  "metadata_exif_value": "Valeur",
174
174
  "metadata_exif_doc": "Doc des attributs EXIF",
175
175
  "metadata_exif_doc_title": "Acééder à la doc Exiv2 pour en savoir plus sur les attributs EXIF et XMP",
176
+ "semantics_title": "Tags",
177
+ "semantics_tags": "{nb} attributs",
178
+ "semantics_features": "Objets détectés",
179
+ "semantics_features_none": "Aucun objet n'a été détecté ou annoté dans cette photo pour l'instant.",
180
+ "semantics_features_default_title": "Objet #{nb}",
181
+ "semantics_features_subtitle": "{nb} attributs associés",
182
+ "semantics_show_all_tags": "Voir tous les attributs",
183
+ "semantics_hide_all_tags": "Masquer les attributs",
184
+ "semantics_key": "Clé",
185
+ "semantics_value": "Valeur",
186
+ "semantics_key_doc": "En savoir plus à propos de cette clé",
187
+ "semantics_value_doc": "En savoir plus à propos de cet attribut",
188
+ "semantics_tags_picture": "Attributs de la photo",
189
+ "semantics_tags_picture_none": "Aucun attribut n'a été défini pour cette photo pour l'instant.",
190
+ "semantics_qualifiers": "Qualificateurs",
191
+ "semantics_wikidata_properties": {
192
+ "P31": "instance de",
193
+ "P279": "sous-classe de",
194
+ "P361": "partie de"
195
+ },
176
196
  "report": "Signaler",
177
197
  "report_auth": "Ce signalement sera envoyé en utilisant votre compte \"{a}\"",
178
198
  "report_nature_label": "Nature du problème",
@@ -0,0 +1,137 @@
1
+ import { PanoramaxPresetsURL } from "./services";
2
+
3
+ // Iconify aliases for whole collections
4
+ const ICON_COLLECTIONS_SUB = {
5
+ "fas": "fa6-solid"
6
+ };
7
+
8
+ // Iconify aliases for specific icons
9
+ const ICON_SUBS = {
10
+ "fa6-solid:directions": "fa6-solid:diamond-turn-right"
11
+ };
12
+
13
+ /**
14
+ * Presets Manager handle retrieval of presets against dedicated endpoint.
15
+ * It allows an easily search between all presets, and handles translations.
16
+ *
17
+ * @class Panoramax.utils.PresetsManager
18
+ * @typicalname presetsManager
19
+ * @param {string} [lang] The user prefered language. Defaults to web browser preferences.
20
+ * @param {boolean} [skipLoad=false] Set to true to disable automatic load of API data
21
+ */
22
+ export default class PresetManager {
23
+ constructor(lang = null, skipLoad = false) {
24
+ this._ready = false;
25
+ this._presets = null;
26
+ this._translations = {};
27
+ if(!skipLoad) { this._load(lang); }
28
+ }
29
+
30
+ /**
31
+ * Downloads various JSON file against presets API.
32
+ * @private
33
+ */
34
+ async _load(lang) {
35
+ lang = lang || window.navigator.languages[0]?.substring(0, 2);
36
+
37
+ try {
38
+ const [translationsENRes, translationsLangRes, presetsRes] = await Promise.all([
39
+ fetch(`${PanoramaxPresetsURL()}/translations/en.min.json`),
40
+ lang ? fetch(`${PanoramaxPresetsURL()}/translations/${lang}.min.json`) : Promise.resolve({ok: true}),
41
+ fetch(`${PanoramaxPresetsURL()}/presets.min.json`),
42
+ ]);
43
+
44
+ if (
45
+ !translationsENRes || !translationsLangRes || !presetsRes
46
+ || !translationsENRes.ok || !translationsLangRes.ok || !presetsRes.ok
47
+ ) {
48
+ this._ready = -1;
49
+ throw new Error("Presets service is not available");
50
+ }
51
+
52
+ this._presets = await presetsRes.json();
53
+ this._translations.en = (await translationsENRes.json())?.en?.presets;
54
+ if(lang) {
55
+ this._translations[lang] = (await translationsLangRes.json())?.[lang]?.presets;
56
+ }
57
+
58
+ // Post-process presets
59
+ Object.entries(this._presets).forEach(([pid,p]) => {
60
+ // Add labels
61
+ if(this._translations[lang]?.presets?.[pid]?.name) {
62
+ p.name = this._translations[lang].presets[pid].name;
63
+ }
64
+ else if(this._translations.en?.presets?.[pid]?.name) {
65
+ p.name = this._translations.en.presets[pid].name;
66
+ }
67
+
68
+ // Create iconify-compatible icon
69
+ if(p.icon) {
70
+ const iconColl = p.icon.split("-")[0];
71
+ const iconCollSub = ICON_COLLECTIONS_SUB[iconColl];
72
+ p.iconify = iconCollSub ? p.icon.replace(iconColl+"-", iconCollSub+":") : p.icon.split("-")[0]+":"+p.icon.split("-").slice(1).join("-");
73
+ if(ICON_SUBS[p.iconify]) { p.iconify = ICON_SUBS[p.iconify]; }
74
+ }
75
+ });
76
+
77
+ this._ready = true;
78
+ } catch (error) {
79
+ console.error("Presets service throws an error:", error);
80
+ this._ready = -1;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolves when the all necessary data was downloaded.
86
+ * @memberOf Panoramax.utils.PresetsManager#
87
+ * @returns {Promise}
88
+ */
89
+ async onceReady() {
90
+ if(!this._ready) {
91
+ await new Promise(resolve => setTimeout(resolve, 250));
92
+ return this.onceReady();
93
+ }
94
+ else if(this._ready === -1) { return Promise.reject(); }
95
+ else { return Promise.resolve(); }
96
+ }
97
+
98
+ /**
99
+ * Find the best matching preset for a given STAC feature.
100
+ * @memberOf Panoramax.utils.PresetsManager#
101
+ * @returns {Promise}
102
+ * @fulfil {object|null} The preset JSON that best matches, or null if no matches
103
+ */
104
+ async getPreset(f) {
105
+ return this.onceReady().then(() => {
106
+ const candidatePresets = Object.values(this._presets).filter(p => {
107
+ if(Object.keys(p.tags).length === 0) { return false; }
108
+
109
+ // Check if every tag on preset exists in feature
110
+ for(let pk in p.tags) {
111
+ const pv = p.tags[pk];
112
+ if(!f.semantics.find(s => s.key === pk && (pv === "*" || pv === s.value))) {
113
+ return false;
114
+ }
115
+ }
116
+ return true;
117
+ });
118
+
119
+ // Find best matching preset
120
+ candidatePresets.sort((a,b) => {
121
+ const nbTagsA = Object.keys(a.tags).length;
122
+ const nbTagsB = Object.keys(b.tags).length;
123
+
124
+ if(nbTagsA > nbTagsB) { return -1; }
125
+ else if(nbTagsA === nbTagsB) {
126
+ const nbTagsStarA = Object.values(a.tags).filter(v => v === "*").length;
127
+ const nbTagsStarB = Object.values(b.tags).filter(v => v === "*").length;
128
+ if(nbTagsStarA < nbTagsStarB) { return -1; }
129
+ else if(nbTagsStarA > nbTagsStarB) { return 1; }
130
+ else { return 0; }
131
+ }
132
+ else { return 1; }
133
+ });
134
+ return candidatePresets.shift();
135
+ });
136
+ }
137
+ }
@@ -2,11 +2,13 @@ import * as geocoder from "./geocoder";
2
2
  import * as i18n from "./i18n";
3
3
  import * as map from "./map";
4
4
  import * as picture from "./picture";
5
+ import * as semantics from "./semantics";
5
6
  import * as services from "./services";
6
7
  import * as utils from "./utils";
7
8
  import * as widgets from "./widgets";
8
9
 
9
- export { geocoder, i18n, map, picture, services, utils, widgets };
10
+ export { geocoder, i18n, map, picture, semantics, services, utils, widgets };
10
11
  export {default as API} from "./API";
11
12
  export {default as PhotoAdapter} from "./PhotoAdapter";
13
+ export {default as PresetsManager} from "./PresetsManager";
12
14
  export {default as URLHandler} from "./URLHandler";
@@ -377,6 +377,11 @@ export function apiFeatureToPSVNode(f, t, fastInternet=false, customLinkFilter=n
377
377
  };
378
378
  }
379
379
 
380
+ // Cleanup empty semantics feature (metacatalog bug)
381
+ if(f.properties?.semantics) {
382
+ f.properties.semantics = f.properties.semantics.filter(o => Object.keys(o).length > 0);
383
+ }
384
+
380
385
  const node = {
381
386
  id: f.id,
382
387
  caption: getNodeCaption(f, t),
@@ -444,4 +449,27 @@ export function filterRelatedPicsLinks(metadata, customFilter = null) {
444
449
  arrowStyle: arrowStyle(l),
445
450
  linkOffset: { yaw: rectifiedYaw }
446
451
  }));
452
+ }
453
+
454
+ /**
455
+ * Read structured hashtags from picture metadata
456
+ * @private
457
+ */
458
+ export function getHashTags(metadata) {
459
+ if(!metadata?.properties?.semantics) { return []; }
460
+
461
+ // Find hashtag entry in semantics
462
+ const hashtagsTags = metadata.properties.semantics.filter(kv => kv.key === "hashtags");
463
+ const readHashTags = [];
464
+ hashtagsTags.forEach(htt => htt.value.split(";").forEach(v => readHashTags.push(v.trim())));
465
+
466
+ return readHashTags;
467
+ }
468
+
469
+ /**
470
+ * Check if a given picture has associated annotations.
471
+ * @private
472
+ */
473
+ export function hasAnnotations(metadata) {
474
+ return metadata?.properties?.annotations?.length > 0;
447
475
  }
@@ -0,0 +1,162 @@
1
+ import { html } from "lit";
2
+ import { fa } from "./widgets";
3
+ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
4
+ import logoOsm from "../img/osm.svg";
5
+ import logoWd from "../img/wd.svg";
6
+ import { OSMWikiURL, WikidataURL } from "./services";
7
+
8
+
9
+ // General helpers
10
+ function valToValLink(val, url, title) {
11
+ return html`
12
+ ${val}
13
+ <pnx-link-button
14
+ kind="superinline"
15
+ size="sm"
16
+ href=${url}
17
+ target="_blank"
18
+ style="vertical-align: middle"
19
+ title=${title}
20
+ >${fa(faInfoCircle)}</pnx-link-button>`;
21
+ }
22
+
23
+ // OSM helpers
24
+ const OSM_MAIN_KEYS = [
25
+ "highway", "building", "amenity", "landuse", "natural", "waterway", "leisure",
26
+ "shop", "railway", "tourism", "barrier", "boundary", "place", "power"
27
+ ];
28
+
29
+ // Supported prefixes for nice semantic display
30
+ const KNOWN_PREFIXES = {
31
+ "": { title: "Panoramax" },
32
+
33
+ osm: {
34
+ title: "OpenStreetMap",
35
+ logo: logoOsm,
36
+ key_transform: (tag, _t) => valToValLink(
37
+ tag.key,
38
+ `${OSMWikiURL()}/Key:${tag.key}`,
39
+ _t.pnx.semantics_key_doc
40
+ ),
41
+ value_transform: (tag, _t) => {
42
+ if(OSM_MAIN_KEYS.includes(tag.key)) {
43
+ return valToValLink(
44
+ tag.value,
45
+ `${OSMWikiURL()}/Tag:${tag.key}=${tag.value}`,
46
+ _t.pnx.semantics_value_doc
47
+ );
48
+ }
49
+ else { return tag.value; }
50
+ }
51
+ },
52
+
53
+ wd: {
54
+ title: "Wikidata",
55
+ logo: logoWd,
56
+ key_transform: (tag, _t) => {
57
+ const url = `${WikidataURL()}/Property:${tag.key}`;
58
+ return _t.pnx.semantics_wikidata_properties[tag.key]
59
+ ? valToValLink(
60
+ `${_t.pnx.semantics_wikidata_properties[tag.key]} (${tag.key})`,
61
+ url,
62
+ _t.pnx.semantics_key_doc
63
+ )
64
+ : valToValLink(tag.key, url, _t.pnx.semantics_key_doc);
65
+ },
66
+ value_transform: (tag, _t) => valToValLink(
67
+ tag.value,
68
+ `${WikidataURL()}/${tag.value}`, _t.pnx.semantics_value_doc
69
+ )
70
+ },
71
+
72
+ exif: { title: "EXIF" },
73
+ };
74
+
75
+
76
+ /**
77
+ * Transform a prefix|key=value into parsed object.
78
+ * Does not handle qualifiers tags.
79
+ * @private
80
+ */
81
+ export function decodeBasicTag(tag) {
82
+ const firstEqual = (tag || "").indexOf("=");
83
+ if(firstEqual < 0) { return null; }
84
+ return {
85
+ key: decodeKey(tag.substring(0, firstEqual)),
86
+ value: tag.substring(firstEqual+1),
87
+ };
88
+ }
89
+
90
+
91
+ /** @private */
92
+ export function decodeKey(key = "") {
93
+ const regex = /^(?:([a-z_]+)\|)?([^[]+)(?:\[(.*)\])?$/;
94
+ const match = key.match(regex);
95
+
96
+ if (!match) {
97
+ return { prefix: "", subkey: key, qualifies: null };
98
+ }
99
+
100
+ const [, prefix, subkey, qualifies ] = match;
101
+ return {
102
+ prefix: prefix || "",
103
+ subkey,
104
+ qualifies: decodeBasicTag(qualifies),
105
+ };
106
+ }
107
+
108
+
109
+ /**
110
+ * Transforms raw API semantics properties into ready-to-display container.
111
+ * @param {object[]} tags The API semantics tags
112
+ * @returns {object[]} A list of groups (by prefix), with {title, tags} information.
113
+ */
114
+ export function groupByPrefix(tags) {
115
+ // Create raw groups by prefix
116
+ const byPrefix = {};
117
+ const qualifiers = [];
118
+
119
+ // First pass: analyze tags, separate by prefix
120
+ tags.forEach(tag => {
121
+ const decodedKey = decodeKey(tag.key);
122
+
123
+ // Put apart qualifiers, to later insert on tags themselves
124
+ if(decodedKey.qualifies) {
125
+ qualifiers.push(Object.assign({}, tag, decodedKey));
126
+ }
127
+ // Process classic tag
128
+ else {
129
+ if (!byPrefix[decodedKey.prefix]) { byPrefix[decodedKey.prefix] = []; }
130
+
131
+ byPrefix[decodedKey.prefix].push(decodedKey.prefix.length > 0 ? {
132
+ key: decodedKey.subkey,
133
+ value: tag.value
134
+ } : tag);
135
+ }
136
+ });
137
+
138
+ // Second pass: add qualifiers on concerned tags
139
+ qualifiers.forEach(({key, prefix, subkey, qualifies, value}) => {
140
+ const concernedTag = byPrefix[qualifies.key.prefix]?.find(t => (
141
+ t.key === qualifies.key.subkey
142
+ && (!qualifies.value || qualifies.value === t.value)
143
+ ));
144
+
145
+ if(concernedTag) {
146
+ if(!concernedTag.qualifiers) { concernedTag.qualifiers = []; }
147
+ concernedTag.qualifiers.push({key, prefix, subkey, value});
148
+ }
149
+ });
150
+
151
+ // Append known prefixes information
152
+ let groups = Object.entries(byPrefix).map(([prefix, prefixTags]) => {
153
+ if(KNOWN_PREFIXES[prefix]) {
154
+ return Object.assign({ prefix, tags: prefixTags }, KNOWN_PREFIXES[prefix]);
155
+ }
156
+ else {
157
+ return { prefix, title: prefix, tags: prefixTags };
158
+ }
159
+ });
160
+
161
+ return groups;
162
+ }
@@ -4,6 +4,36 @@
4
4
  */
5
5
 
6
6
 
7
+ /**
8
+ * Panoramax Presets URL
9
+ * @returns {string} The presets URL
10
+ */
11
+ export function PanoramaxPresetsURL() {
12
+ return "https://tagging-presets-22605e.gitlab.io";
13
+ }
14
+
15
+ /**
16
+ * Temaki icons URL
17
+ * @param {string} [iconName] The icon name to insert in URL
18
+ * @returns {string} The SVG icon URL
19
+ */
20
+ export function TemakiIconURL(iconName) {
21
+ return `https://raw.githubusercontent.com/rapideditor/temaki/refs/heads/main/icons/${iconName}.svg`;
22
+ }
23
+
24
+ /**
25
+ * Wikidata URL
26
+ * @returns {string} The Wikidata URL
27
+ */
28
+ export function WikidataURL() {
29
+ return "https://www.wikidata.org/wiki";
30
+ }
31
+
32
+
33
+ /* -----------------------------------------------------
34
+ * OpenStreetMap docs & tools
35
+ */
36
+
7
37
  /**
8
38
  * OpenStreetMap iD editor URL
9
39
  * @returns {string} The editor URL
@@ -12,6 +42,14 @@ export function IdEditorURL() {
12
42
  return "https://www.openstreetmap.org/edit?editor=id";
13
43
  }
14
44
 
45
+ /**
46
+ * OpenStreetMap wiki URL
47
+ * @returns {string} The wiki URL
48
+ */
49
+ export function OSMWikiURL() {
50
+ return "https://wiki.openstreetmap.org/wiki";
51
+ }
52
+
15
53
 
16
54
  /* -----------------------------------------------------
17
55
  * Internet speed tests
@@ -1,5 +1,6 @@
1
- // Every single icon imported separately to reduce bundle size
2
1
  import { icon } from "@fortawesome/fontawesome-svg-core";
2
+ import { setCustomIconLoader } from "iconify-icon";
3
+ import { TemakiIconURL } from "./services";
3
4
 
4
5
 
5
6
  /**
@@ -13,6 +14,22 @@ export function fa(i, o) {
13
14
  return icon(i, o).node[0];
14
15
  }
15
16
 
17
+ /**
18
+ * Register more icons providers for Iconify
19
+ * @private
20
+ */
21
+ export function moreIcons() {
22
+ setCustomIconLoader(async (name) => {
23
+ const response = await fetch(TemakiIconURL(name));
24
+ if (!response.ok) {
25
+ return null;
26
+ }
27
+ return {
28
+ body: await response.text()
29
+ };
30
+ }, "temaki");
31
+ }
32
+
16
33
  /**
17
34
  * Create a web component with its initial properties
18
35
  * @private
@@ -23,6 +23,12 @@ exports[`_initWidgets should handle widgets if width is not small 1`] = `
23
23
  slot="top-left"
24
24
  />,
25
25
  ],
26
+ Array [
27
+ <pnx-hashtags
28
+ class="pnx-only-psv pnx-print-hidden"
29
+ slot="top-right"
30
+ />,
31
+ ],
26
32
  ],
27
33
  "results": Array [
28
34
  Object {
@@ -37,6 +43,10 @@ exports[`_initWidgets should handle widgets if width is not small 1`] = `
37
43
  "type": "return",
38
44
  "value": undefined,
39
45
  },
46
+ Object {
47
+ "type": "return",
48
+ "value": undefined,
49
+ },
40
50
  ],
41
51
  }
42
52
  `;
@@ -16,6 +16,12 @@ exports[`_initWidgets should handle widgets if width is not small 1`] = `
16
16
  slot="top-left"
17
17
  />,
18
18
  ],
19
+ Array [
20
+ <pnx-hashtags
21
+ class="pnx-only-psv pnx-print-hidden"
22
+ slot="top-right"
23
+ />,
24
+ ],
19
25
  Array [
20
26
  <pnx-widget-player
21
27
  class="pnx-only-psv pnx-print-hidden"
@@ -68,6 +74,10 @@ exports[`_initWidgets should handle widgets if width is not small 1`] = `
68
74
  "type": "return",
69
75
  "value": undefined,
70
76
  },
77
+ Object {
78
+ "type": "return",
79
+ "value": undefined,
80
+ },
71
81
  ],
72
82
  }
73
83
  `;
@@ -0,0 +1,123 @@
1
+ import PresetsManager from "../../src/utils/PresetsManager";
2
+
3
+ const mockPresets = {
4
+ "osm_traffic_sign": {
5
+ "icon": "fas-directions",
6
+ "tags": {
7
+ "osm|traffic_sign": "*"
8
+ },
9
+ "geometry": [
10
+ "point",
11
+ "vertex"
12
+ ]
13
+ },
14
+ "osm_traffic_calming": {
15
+ "icon": "temaki-diamond",
16
+ "tags": {
17
+ "osm|traffic_calming": "*"
18
+ },
19
+ "geometry": [
20
+ "vertex"
21
+ ]
22
+ },
23
+ "osm_shop": {
24
+ "icon": "maki-shop",
25
+ "tags": {
26
+ "osm|shop": "*"
27
+ },
28
+ "geometry": [
29
+ "point",
30
+ "area"
31
+ ]
32
+ },
33
+ };
34
+
35
+ // Mock des appels fetch
36
+ global.fetch = jest.fn(() =>
37
+ Promise.resolve({
38
+ ok: true,
39
+ json: () => Promise.resolve({}),
40
+ })
41
+ );
42
+
43
+ beforeEach(() => {
44
+ fetch.mockClear();
45
+ });
46
+
47
+ it("should initialize with default values", () => {
48
+ const presetsManager = new PresetsManager(null, true);
49
+ expect(presetsManager._ready).toBe(false);
50
+ expect(presetsManager._presets).toBe(null);
51
+ expect(presetsManager._translations).toEqual({});
52
+ });
53
+
54
+ it("should load translations and presets", async () => {
55
+ const mockTranslationsEN = { en: { presets: { preset1: { name: "Preset 1" } } } };
56
+ const mockTranslationsFR = { fr: { presets: { preset1: { name: "Préréglage 1" } } } };
57
+
58
+ fetch.mockImplementationOnce(() =>
59
+ Promise.resolve({
60
+ ok: true,
61
+ json: () => Promise.resolve(mockTranslationsEN),
62
+ })
63
+ ).mockImplementationOnce(() =>
64
+ Promise.resolve({
65
+ ok: true,
66
+ json: () => Promise.resolve(mockTranslationsFR),
67
+ })
68
+ ).mockImplementationOnce(() =>
69
+ Promise.resolve({
70
+ ok: true,
71
+ json: () => Promise.resolve(mockPresets),
72
+ })
73
+ );
74
+
75
+ const presetsManager = new PresetsManager("fr");
76
+ await presetsManager.onceReady();
77
+
78
+ expect(presetsManager._translations.en).toEqual(mockTranslationsEN.en.presets);
79
+ expect(presetsManager._translations.fr).toEqual(mockTranslationsFR.fr.presets);
80
+ expect(presetsManager._presets).toEqual(mockPresets);
81
+ expect(presetsManager._ready).toBe(true);
82
+ });
83
+
84
+ it("should handle errors during load", async () => {
85
+ global.console = { error: jest.fn() };
86
+
87
+ fetch.mockImplementation(() =>
88
+ Promise.resolve({
89
+ ok: false,
90
+ })
91
+ );
92
+
93
+ const presetsManager = new PresetsManager();
94
+ await expect(presetsManager.onceReady()).rejects.toBeUndefined();
95
+ expect(presetsManager._ready).toBe(-1);
96
+ });
97
+
98
+ it("should find the best matching preset", async () => {
99
+ fetch.mockImplementationOnce(() =>
100
+ Promise.resolve({
101
+ ok: true,
102
+ json: () => Promise.resolve({}),
103
+ })
104
+ ).mockImplementationOnce(() =>
105
+ Promise.resolve({
106
+ ok: true,
107
+ json: () => Promise.resolve({}),
108
+ })
109
+ ).mockImplementationOnce(() =>
110
+ Promise.resolve({
111
+ ok: true,
112
+ json: () => Promise.resolve(mockPresets),
113
+ })
114
+ );
115
+
116
+ const presetsManager = new PresetsManager();
117
+ await presetsManager.onceReady();
118
+
119
+ const feature = { semantics: [{ key: "key1", value: "value1" }, { key: "key2", value: "value2" }] };
120
+ const bestPreset = await presetsManager.getPreset(feature);
121
+
122
+ expect(bestPreset).toEqual(mockPresets.preset2);
123
+ });