@panoramax/web-viewer 4.0.2 → 4.0.3-develop-d59993ce

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 (57) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/build/index.css +2 -2
  3. package/build/index.css.map +1 -1
  4. package/build/index.js +287 -72
  5. package/build/index.js.map +1 -1
  6. package/config/jest/mocks.js +5 -0
  7. package/docs/09_Develop.md +6 -0
  8. package/docs/reference/components/core/PhotoViewer.md +1 -0
  9. package/docs/reference/components/core/Viewer.md +2 -1
  10. package/docs/reference/components/menus/AnnotationsList.md +16 -0
  11. package/docs/reference/components/ui/HashTags.md +15 -0
  12. package/docs/reference/components/ui/ListItem.md +38 -0
  13. package/docs/reference/components/ui/Photo.md +78 -1
  14. package/docs/reference/components/ui/SemanticsTable.md +32 -0
  15. package/docs/reference/components/ui/widgets/GeoSearch.md +5 -1
  16. package/docs/reference/utils/PresetsManager.md +35 -0
  17. package/docs/reference.md +4 -0
  18. package/mkdocs.yml +4 -0
  19. package/package.json +2 -1
  20. package/src/components/core/Basic.css +2 -0
  21. package/src/components/core/PhotoViewer.js +11 -0
  22. package/src/components/core/Viewer.js +8 -1
  23. package/src/components/layout/Tabs.js +1 -1
  24. package/src/components/menus/AnnotationsList.js +151 -0
  25. package/src/components/menus/PictureLegend.js +6 -5
  26. package/src/components/menus/PictureMetadata.js +80 -8
  27. package/src/components/menus/index.js +1 -0
  28. package/src/components/styles.js +34 -0
  29. package/src/components/ui/HashTags.js +98 -0
  30. package/src/components/ui/ListItem.js +83 -0
  31. package/src/components/ui/Photo.js +188 -0
  32. package/src/components/ui/SemanticsTable.js +87 -0
  33. package/src/components/ui/index.js +3 -0
  34. package/src/components/ui/widgets/GeoSearch.js +13 -5
  35. package/src/img/osm.svg +49 -0
  36. package/src/img/wd.svg +1 -0
  37. package/src/translations/en.json +23 -0
  38. package/src/translations/fr.json +21 -0
  39. package/src/translations/it.json +28 -1
  40. package/src/translations/nl.json +14 -4
  41. package/src/translations/zh_Hant.json +6 -1
  42. package/src/utils/PresetsManager.js +137 -0
  43. package/src/utils/URLHandler.js +1 -1
  44. package/src/utils/geocoder.js +135 -83
  45. package/src/utils/index.js +3 -1
  46. package/src/utils/picture.js +28 -0
  47. package/src/utils/semantics.js +162 -0
  48. package/src/utils/services.js +39 -1
  49. package/src/utils/widgets.js +18 -1
  50. package/tests/components/core/__snapshots__/PhotoViewer.test.js.snap +10 -0
  51. package/tests/components/core/__snapshots__/Viewer.test.js.snap +10 -0
  52. package/tests/data/Map_geocoder_nominatim.json +25 -40
  53. package/tests/utils/PresetsManager.test.js +123 -0
  54. package/tests/utils/URLHandler.test.js +42 -0
  55. package/tests/utils/__snapshots__/geocoder.test.js.snap +5 -16
  56. package/tests/utils/geocoder.test.js +1 -1
  57. package/tests/utils/semantics.test.js +125 -0
@@ -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
+ }
@@ -150,7 +150,7 @@ export default class URLHandler extends EventTarget {
150
150
  // If hash is compressed
151
151
  if(keyvals.s) {
152
152
  const shortVals = Object.fromEntries(
153
- keyvals.s
153
+ decodeURIComponent(keyvals.s)
154
154
  .split(";")
155
155
  .map(kv => [kv[0], kv.substring(1)])
156
156
  );
@@ -3,6 +3,20 @@ import maplibregl from "!maplibre-gl";
3
3
 
4
4
  import { NominatimBaseUrl, AdresseDataGouvBaseURL } from "./services";
5
5
 
6
+ const PLACETYPE_ZOOM = {
7
+ "house": 20,
8
+ "housenumber": 20,
9
+ "street": 18,
10
+ "locality": 15,
11
+ "district": 13,
12
+ "municipality": 12,
13
+ "city": 12,
14
+ "county": 8,
15
+ "region": 7,
16
+ "state": 7,
17
+ "country": 5
18
+ };
19
+
6
20
  /**
7
21
  * Transforms a set of parameters into an URL-ready string
8
22
  * It also removes null/undefined values
@@ -20,69 +34,6 @@ function geocoderParamsToURLString(params) {
20
34
  return new URLSearchParams(p).toString();
21
35
  }
22
36
 
23
- /**
24
- * Transforms Nominatim search result into a nice-to-display address.
25
- * @param {object} addr The Nominatim API "address" property
26
- * @returns {string} The clean-up string for display
27
- * @private
28
- */
29
- function nominatimAddressToPlaceName(addr) {
30
- // API format @ https://nominatim.org/release-docs/develop/api/Output/#addressdetails
31
- if(!addr || typeof addr != "object") { return ""; }
32
-
33
- let res = "";
34
-
35
- // House n°-like
36
- if(addr.house_number) { res = addr.house_number; }
37
- else if(addr.house_name) { res = addr.house_name; }
38
- else {
39
- const potentialNames = [
40
- "emergency", "historic", "military", "natural", "landuse", "place", "railway", "man_made",
41
- "aerialway", "boundary", "amenity", "aeroway", "club", "craft", "leisure", "office",
42
- "mountain_pass", "shop", "tourism", "bridge", "tunnel", "waterway", "park"
43
- ];
44
- for(let pn of potentialNames) {
45
- if(addr[pn]) {
46
- res = addr[pn];
47
- break;
48
- }
49
- }
50
- }
51
-
52
- // Street-like
53
- let street;
54
- if(addr.road && addr.road.length > 6) { street = addr.road; }
55
- else {
56
- const potentialNames = [
57
- // Hamlet-like
58
- "hamlet", "croft", "isolated_dwelling",
59
- // Zone Indus-like
60
- "farm", "farmyard", "industrial", "commercial", "retail", "city_block", "residential",
61
- // Quarter-like
62
- "neighbourhood", "allotments", "quarter",
63
- // Fallback to road if nothing else found
64
- "road"
65
- ];
66
- for(let pn of potentialNames) {
67
- if(addr[pn]) {
68
- street = addr[pn];
69
- break;
70
- }
71
- }
72
- }
73
-
74
- if(street && res.length > 0) { res += (addr.house_number ? " " : ", ")+street; }
75
- else if(street) { res = street; }
76
-
77
- // City
78
- if(addr.village || addr.town || addr.city || addr.municipality) {
79
- if(res.length > 0) { res += ", "; }
80
- res += addr.village || addr.town || addr.city || addr.municipality;
81
- }
82
-
83
- return res;
84
- }
85
-
86
37
  /**
87
38
  * Nominatim (OSM) geocoder, ready to use for our Map
88
39
  * @private
@@ -96,18 +47,19 @@ export function forwardGeocodingNominatim(config) {
96
47
  viewbox: config.bbox,
97
48
  };
98
49
 
99
- return fetch(`${NominatimBaseUrl()}/search?${geocoderParamsToURLString(params)}&format=geojson&polygon_geojson=1&addressdetails=1`)
50
+ return fetch(`${NominatimBaseUrl()}/search?${geocoderParamsToURLString(params)}&format=geocodejson&addressdetails=1`)
100
51
  .then(res => res.json())
101
52
  .then(res => {
102
53
  const finalRes = { features: [] };
103
54
  const listedNames = [];
104
- res.features.forEach(f => {
105
- const plname = nominatimAddressToPlaceName(f.properties.address) || f.properties.display_name;
55
+ (res.features || []).forEach(f => {
56
+ const plname = geocodeJsonToPlaceName(f.properties?.geocoding) || f.properties?.geocoding?.label;
106
57
  if(!listedNames.includes(plname)) {
107
58
  finalRes.features.push({
108
59
  place_type: ["place"],
109
60
  place_name: plname,
110
- bounds: new maplibregl.LngLatBounds(f.bbox),
61
+ center: new maplibregl.LngLat(...f.geometry.coordinates),
62
+ zoom: PLACETYPE_ZOOM[f.properties?.geocoding?.type],
111
63
  });
112
64
  listedNames.push(plname);
113
65
  }
@@ -117,18 +69,113 @@ export function forwardGeocodingNominatim(config) {
117
69
  }
118
70
 
119
71
  export function reverseGeocodingNominatim(lat, lon) {
120
- return fetch(`${NominatimBaseUrl()}/reverse?lat=${lat}&lon=${lon}&zoom=18&format=jsonv2`)
72
+ return fetch(`${NominatimBaseUrl()}/reverse?lat=${lat}&lon=${lon}&zoom=18&format=geocodejson`)
121
73
  .then(res => res.json())
122
- .then(res => nominatimAddressToPlaceName(res?.address));
74
+ .then(res => geocodeJsonToPlaceName(res?.features?.shift()?.properties?.geocoding));
123
75
  }
124
76
 
125
77
  /**
126
78
  * Base adresse nationale (FR) geocoder, ready to use for our Map
127
- * @param {object} config Configuration sent by MapLibre GL Geocoder, following the geocoderApi format ( https://github.com/maplibre/maplibre-gl-geocoder/blob/main/API.md#setgeocoderapi )
128
- * @returns {object} GeoJSON Feature collection in Carmen GeoJSON format
79
+ * @param {object} config Configuration sent by MapLibre GL Geocoder, following the geocoderApi format ( https://maplibre.org/maplibre-gl-geocoder/types/MaplibreGeocoderApiConfig.html )
80
+ * @returns {object} GeoJSON Feature collection in Carmen GeoJSON format ( https://maplibre.org/maplibre-gl-geocoder/types/CarmenGeojsonFeature.html )
129
81
  * @private
130
82
  */
131
83
  export function forwardGeocodingBAN(config) {
84
+ return forwardGeocodingStandard(config, AdresseDataGouvBaseURL());
85
+ }
86
+
87
+ /**
88
+ * Transforms GeocodeJSON search result into a nice-to-display address.
89
+ * @param {object} props The GecodeJSON API feature properties
90
+ * @returns {string} The clean-up string for display
91
+ * @private
92
+ */
93
+ function geocodeJsonToPlaceName(props) {
94
+ // API format @ https://github.com/geocoders/geocodejson-spec/blob/master/draft/README.md
95
+ if(!props || typeof props != "object") { return ""; }
96
+
97
+ // P1 = main name, P2=locality-like, P3=country+high-level admin
98
+ let p1 = props.name;
99
+ let p2 = [], p3 = [];
100
+
101
+ switch(props.type) {
102
+ case "hamlet":
103
+ case "croft":
104
+ case "isolated_dwelling":
105
+ case "neighbourhood":
106
+ case "allotments":
107
+ case "quarter":
108
+ case "farm":
109
+ case "farmyard":
110
+ case "industrial":
111
+ case "commercial":
112
+ case "retail":
113
+ case "city_block":
114
+ case "residential":
115
+ case "locality":
116
+ case "district":
117
+ p3.push(props.city);
118
+ p3.push(props.county);
119
+ p3.push(props.state);
120
+ p3.push(props.country);
121
+ break;
122
+ case "city":
123
+ p3.push(props.county);
124
+ p3.push(props.state);
125
+ p3.push(props.country);
126
+ break;
127
+ case "region":
128
+ p3.push(props.county);
129
+ p3.push(props.state);
130
+ p3.push(props.country);
131
+ break;
132
+ case "country":
133
+ break;
134
+ case "house":
135
+ case "housenumber":
136
+ p2.push(props.housenumber);
137
+ p2.push(props.street);
138
+ p2.push(props.locality);
139
+ p2.push(props.district);
140
+ p3.push(props.city);
141
+ p3.push(props.county);
142
+ p3.push(props.state);
143
+ p3.push(props.country);
144
+ break;
145
+ case "street":
146
+ case "road":
147
+ default:
148
+ p2.push(props.street);
149
+ p2.push(props.locality);
150
+ p2.push(props.district);
151
+ p3.push(props.city);
152
+ p3.push(props.county);
153
+ p3.push(props.state);
154
+ p3.push(props.country);
155
+ break;
156
+ }
157
+
158
+ p2 = p2.filter(v => v);
159
+ p2 = p2.filter((v,i) => v != p1 && (i === 0 || p2[i-1] !== v));
160
+ p2 = p2.length > 0 ? (props.housenumber ? p2.slice(0,2).join(" ") : p2.shift()) : null;
161
+ if(p2 === p1) { p2 = null; }
162
+
163
+ p3 = p3.filter(v => v);
164
+ p3 = p3.filter((v,i) => v != p1 && (!p2 || !p2.includes(v)) && (i === 0 || p3[i-1] !== v));
165
+
166
+ let res = [p1, p2, p3.shift()].filter(v => v);
167
+
168
+ return res.join(", ");
169
+ }
170
+
171
+ /**
172
+ * Standard forward geocoder
173
+ * @param {object} config Configuration sent by MapLibre GL Geocoder, following the geocoderApi format ( https://maplibre.org/maplibre-gl-geocoder/types/MaplibreGeocoderApiConfig.html )
174
+ * @param {string} endpoint The URL endpoint (everything before the /?q=...)
175
+ * @returns {object} GeoJSON Feature collection in Carmen GeoJSON format ( https://maplibre.org/maplibre-gl-geocoder/types/CarmenGeojsonFeature.html )
176
+ * @private
177
+ */
178
+ export function forwardGeocodingStandard(config, endpoint) {
132
179
  // Transform parameters into BAN format
133
180
  const params = { q: config.query, limit: config.limit };
134
181
  if(typeof config.proximity === "string") {
@@ -137,18 +184,23 @@ export function forwardGeocodingBAN(config) {
137
184
  params.lon = lon;
138
185
  }
139
186
 
140
- const toPlaceName = p => [p.name, p.district, p.city].filter(v => v).join(", ");
141
- const placeTypeToZoom = { "housenumber": 20, "street": 18, "locality": 15, "municipality": 12 };
142
-
143
- return fetch(`${AdresseDataGouvBaseURL()}/search/?${geocoderParamsToURLString(params)}`)
187
+ return fetch(`${endpoint}/?${geocoderParamsToURLString(params)}`)
144
188
  .then(res => res.json())
145
189
  .then(res => {
146
- res.features = res.features.map(f => ({
147
- place_type: ["place"],
148
- place_name: toPlaceName(f.properties),
149
- center: new maplibregl.LngLat(...f.geometry.coordinates),
150
- zoom: placeTypeToZoom[f.properties.type],
151
- }));
152
- return res;
190
+ const finalRes = { features: [] };
191
+ const listedNames = [];
192
+ (res.features || []).forEach(f => {
193
+ const plname = geocodeJsonToPlaceName(f.properties);
194
+ if(!listedNames.includes(plname) && f.properties.type != "other") {
195
+ finalRes.features.push({
196
+ place_type: ["place"],
197
+ place_name: plname,
198
+ center: new maplibregl.LngLat(...f.geometry.coordinates),
199
+ zoom: PLACETYPE_ZOOM[f.properties.type],
200
+ });
201
+ listedNames.push(plname);
202
+ }
203
+ });
204
+ return finalRes;
153
205
  });
154
- }
206
+ }
@@ -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://presets.panoramax.fr";
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
@@ -45,7 +83,7 @@ export function InternetFastTestFile() {
45
83
  * @returns {string} The Base Adresse Nationale URL (must support /search calls).
46
84
  */
47
85
  export function AdresseDataGouvBaseURL() {
48
- return "https://data.geopf.fr/geocodage";
86
+ return "https://data.geopf.fr/geocodage/search";
49
87
  }
50
88
 
51
89
  /**