@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.
- package/CHANGELOG.md +18 -1
- package/build/index.css +2 -2
- package/build/index.css.map +1 -1
- package/build/index.js +287 -72
- package/build/index.js.map +1 -1
- package/config/jest/mocks.js +5 -0
- package/docs/09_Develop.md +6 -0
- package/docs/reference/components/core/PhotoViewer.md +1 -0
- package/docs/reference/components/core/Viewer.md +2 -1
- package/docs/reference/components/menus/AnnotationsList.md +16 -0
- package/docs/reference/components/ui/HashTags.md +15 -0
- package/docs/reference/components/ui/ListItem.md +38 -0
- package/docs/reference/components/ui/Photo.md +78 -1
- package/docs/reference/components/ui/SemanticsTable.md +32 -0
- package/docs/reference/components/ui/widgets/GeoSearch.md +5 -1
- package/docs/reference/utils/PresetsManager.md +35 -0
- package/docs/reference.md +4 -0
- package/mkdocs.yml +4 -0
- package/package.json +2 -1
- package/src/components/core/Basic.css +2 -0
- package/src/components/core/PhotoViewer.js +11 -0
- package/src/components/core/Viewer.js +8 -1
- package/src/components/layout/Tabs.js +1 -1
- package/src/components/menus/AnnotationsList.js +151 -0
- package/src/components/menus/PictureLegend.js +6 -5
- package/src/components/menus/PictureMetadata.js +80 -8
- package/src/components/menus/index.js +1 -0
- package/src/components/styles.js +34 -0
- package/src/components/ui/HashTags.js +98 -0
- package/src/components/ui/ListItem.js +83 -0
- package/src/components/ui/Photo.js +188 -0
- package/src/components/ui/SemanticsTable.js +87 -0
- package/src/components/ui/index.js +3 -0
- package/src/components/ui/widgets/GeoSearch.js +13 -5
- package/src/img/osm.svg +49 -0
- package/src/img/wd.svg +1 -0
- package/src/translations/en.json +23 -0
- package/src/translations/fr.json +21 -0
- package/src/translations/it.json +28 -1
- package/src/translations/nl.json +14 -4
- package/src/translations/zh_Hant.json +6 -1
- package/src/utils/PresetsManager.js +137 -0
- package/src/utils/URLHandler.js +1 -1
- package/src/utils/geocoder.js +135 -83
- package/src/utils/index.js +3 -1
- package/src/utils/picture.js +28 -0
- package/src/utils/semantics.js +162 -0
- package/src/utils/services.js +39 -1
- package/src/utils/widgets.js +18 -1
- package/tests/components/core/__snapshots__/PhotoViewer.test.js.snap +10 -0
- package/tests/components/core/__snapshots__/Viewer.test.js.snap +10 -0
- package/tests/data/Map_geocoder_nominatim.json +25 -40
- package/tests/utils/PresetsManager.test.js +123 -0
- package/tests/utils/URLHandler.test.js +42 -0
- package/tests/utils/__snapshots__/geocoder.test.js.snap +5 -16
- package/tests/utils/geocoder.test.js +1 -1
- 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
|
+
}
|
package/src/utils/URLHandler.js
CHANGED
|
@@ -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
|
);
|
package/src/utils/geocoder.js
CHANGED
|
@@ -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=
|
|
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 =
|
|
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
|
-
|
|
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=
|
|
72
|
+
return fetch(`${NominatimBaseUrl()}/reverse?lat=${lat}&lon=${lon}&zoom=18&format=geocodejson`)
|
|
121
73
|
.then(res => res.json())
|
|
122
|
-
.then(res =>
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
}
|
package/src/utils/index.js
CHANGED
|
@@ -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";
|
package/src/utils/picture.js
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/services.js
CHANGED
|
@@ -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
|
/**
|