@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
824 lines (745 loc) • 25.3 kB
JavaScript
import { TILES_PICTURES_ZOOM } from "./map";
import { isNullId } from "./utils";
/**
* API contains various utility functions to communicate with Panoramax/STAC API
*
* @class Panoramax.utils.API
* @typicalname api
* @fires Panoramax.utils.API#ready
* @fires Panoramax.utils.API#broken
* @param {string} endpoint The endpoint. It corresponds to the <a href="https://github.com/radiantearth/stac-api-spec/blob/main/overview.md#example-landing-page">STAC landing page</a>, with all links describing the API capabilities.
* @param {object} [options] Options
* @param {string|object} [options.style] General map style
* @param {string} [options.tiles] API route serving pictures & sequences vector tiles
* @param {boolean} [options.skipReadLanding=false] True to not call API landing page automatically
* @param {object} [options.fetch] Set custom options for fetch calls made against API ([same syntax as fetch options parameter](https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters))
* @param {string[]} [options.users] List of initial user IDs to load map styles for
*/
export default class API extends EventTarget {
constructor(endpoint, options = {}) {
super();
if(endpoint === null || endpoint === undefined || typeof endpoint !== "string") {
const e = new Error("endpoint parameter is empty or not a valid string");
this.dispatchEvent(new CustomEvent("broken", {detail: {error: e}}));
throw e;
}
// Parse local endpoints
if(endpoint.startsWith("/")) {
endpoint = window.location.href.split("/").slice(0, 3).join("/") + endpoint;
}
// Check endpoint
if(!API.isValidHttpUrl(endpoint)) {
const e = new Error(`endpoint parameter is not a valid URL: ${endpoint}`);
this.dispatchEvent(new CustomEvent("broken", {detail: {error: e}}));
throw e;
}
this._endpoint = endpoint;
this._isReady = 0;
this._dataBbox = null;
this._fetchOpts = options?.fetch || {};
this._metadata = {};
if(options.skipReadLanding) { return; }
this._readLanding = fetch(endpoint, this._getFetchOptions())
.then(res => res.json())
.then(landing => this._parseLanding(landing, options))
.catch(e => {
this._isReady = -1;
console.error(e);
/**
* Event when API is broken.
* This happens on any API loading or map styling issue.
* @event Panoramax.utils.API#broken
* @type {CustomEvent}
* @property {Error} detail.error The original error
*/
this.dispatchEvent(new CustomEvent("broken", {detail: {error: e}}));
return Promise.reject("Viewer failed to communicate with API");
})
.then(() => this._loadMapStyles(options.style, options.users))
.then(() => {
this._isReady = 1;
/**
* Event when API is ready to use.
* This happens after initial API read and map styles load.
* @event Panoramax.utils.API#ready
* @type {Event}
*/
this.dispatchEvent(new Event("ready"));
return "API is ready";
});
}
/**
* Resolves when the API is ready to be used.
* @memberOf Panoramax.utils.API#
* @returns {Promise}
* @fulfil {string} "API is ready" when initialization is complete.
* @reject {string} Error message
*/
onceReady() {
if(this._isReady == -1) {
return Promise.reject("Viewer failed to communicate with API");
}
else if(this._isReady == 1) {
return Promise.resolve("API is ready");
}
else {
return this._readLanding;
}
}
/**
* Checks if the API is ready to be used.
* @memberOf Panoramax.utils.API#
* @returns {boolean} True if the API is ready, false otherwise.
*/
isReady() {
return this._isReady == 1;
}
/**
* List of available features offered by API
* @memberOf Panoramax.utils.API#
* @returns {string[]} Keywords of enabled features
*/
getAvailableFeatures() {
return Object.entries(this._endpoints).filter(e => e[1] !== null).map(e => e[0]);
}
/**
* List of unavailable features on API
* @memberOf Panoramax.utils.API#
* @returns {string[]} Keywords of disabled features
*/
getUnavailableFeatures() {
return Object.entries(this._endpoints).filter(e => e[1] === null).map(e => e[0]);
}
/**
* Interprets JSON landing page and store important information
* @memberOf Panoramax.utils.API#
* @private
*/
_parseLanding(landing, options) {
this._endpoints = {
"collections": null,
"search": null,
"style": null,
"user_style": null,
"tiles": options?.tiles || null,
"user_tiles": null,
"user_search": null,
"collection_preview": null,
"item_preview": null,
"rss": null,
"report": null,
};
if(!landing || !landing.links || !Array.isArray(landing.links)) {
throw new Error("API Landing page doesn't contain 'links' list");
}
if(!landing.stac_version.startsWith("1.")) {
throw new Error(`API is not in a supported STAC version (Panoramax viewer supports only 1.x, API is ${landing.stac_version})`);
}
// Read metadata
this._metadata.name = landing.title || "Unnamed";
this._metadata.stac_version = landing.stac_version;
this._metadata.geovisio_version = landing.geovisio_version;
// Read links
const supportedLinks = [
{
rel: "search",
type: "application/geo+json",
endpointId: "search",
mandatory: true,
missingIssue: "No direct access to pictures metadata."
},
{
rel: "data",
type: "application/json",
endpointId: "collections",
mandatory: true,
missingIssue: "No way for viewer to access sequences."
},
{
rel: "data",
type: "application/rss+xml",
endpointId: "rss"
},
{
rel: "xyz",
type: "application/vnd.mapbox-vector-tile",
endpointId: "tiles"
},
{
rel: "xyz-style",
type: "application/json",
endpointId: "style"
},
{
rel: "user-xyz-style",
type: "application/json",
endpointId: "user_style"
},
{
rel: "user-xyz",
type: "application/vnd.mapbox-vector-tile",
endpointId: "user_tiles"
},
{
rel: "user-search",
type: "application/json",
endpointId: "user_search",
missingIssue: "Filter map data by user name will not be available."
},
{
rel: "collection-preview",
type: "image/jpeg",
endpointId: "collection_preview",
missingIssue: "Display of thumbnail could be slower."
},
{
rel: "item-preview",
type: "image/jpeg",
endpointId: "item_preview",
missingIssue: "Display of thumbnail could be slower."
},
{
rel: "report",
type: "application/json",
endpointId: "report"
}
];
const blockingIssues = [];
const warningIssues = [];
supportedLinks.forEach(sl => {
// Find link in landing
const ll = landing.links.find(ll => ll.rel == sl.rel && ll.type == sl.type);
// No link found
if(!ll) {
if(!this._endpoints[sl.endpointId]) {
let label = `API doesn't offer a '${sl.rel}' (${sl.type}) endpoint in its links`;
if(sl.missingIssue) { label += `\n${sl.missingIssue}`; }
// Display issue (either blocking or not)
if(sl.mandatory) { blockingIssues.push(label); }
else if(sl.missingIssue) { warningIssues.push(label); }
}
}
// Link found
else {
// Invalid link
if(!API.isValidHttpUrl(ll.href)) {
throw new Error(`API endpoint '${ll.rel}' (${ll.type}) is not a valid URL: ${ll.href}`);
}
// Valid link -> stored in endpoints
if(!this._endpoints[sl.endpointId]) {
this._endpoints[sl.endpointId] = ll.href;
}
}
});
// Complex checks
if(!this._endpoints.style && !this._endpoints.tiles) {
warningIssues.push("API doesn't offer 'xyz' or 'xyz-style' endpoints in its links.\nMap widget will not be available.");
}
if(!this._endpoints.user_style && !this._endpoints.user_tiles) {
warningIssues.push("API doesn't offer 'user-xyz' or 'user-xyz-style' endpoints in its links.\nFilter map data by user ID will not be available.");
}
// Display warnings & errors
warningIssues.forEach(w => console.warn(w));
if(blockingIssues.length > 0) {
throw new Error(blockingIssues.join("\n"));
}
// Look for data BBox
const bbox = landing?.extent?.spatial?.bbox;
this._dataBbox = (
bbox &&
Array.isArray(bbox) &&
bbox.length > 0 &&
Array.isArray(bbox[0]) && bbox[0].length === 4
) ?
[[bbox[0][0], bbox[0][1]], [bbox[0][2], bbox[0][3]]]
: null;
}
/**
* Loads all MapLibre Styles JSON needed at start.
* @memberOf Panoramax.utils.API#
* @param {string|object} style General map style
* @param {string[]} users List of user IDs to handle. Should include special user "geovisio" for general tiles loading.
* @returns {Promise}
* @fulfil {null} When style is ready.
* @private
*/
_loadMapStyles(style, users) {
const mapUsers = new Set(users || []);
// Load all necessary map styles
this.mapStyle = { version: 8, sources: {}, layers: [], metadata: {} };
const stylePromises = [ this.getMapStyle() ];
// General map style
if(typeof style === "string") {
const fetchOpts = style.startsWith(this._endpoint) ? this._getFetchOptions() : undefined;
stylePromises.push(fetch(style, fetchOpts).then(res => res.json()));
}
else if(typeof style === "object") {
stylePromises.push(Promise.resolve(style));
}
// By-user style
[...mapUsers].filter(mu => mu !== "geovisio").forEach(mu => {
stylePromises.push(this.getUserMapStyle(mu, true));
});
return Promise.all(stylePromises)
.then(styles => {
const overridableProps = [
"bearing", "center", "glyphs", "light", "name",
"pitch", "sky", "sprite", "terrain", "transition", "zoom"
];
styles.forEach(style => {
overridableProps.forEach(p => {
if(style[p]) { this.mapStyle[p] = style[p] || this.mapStyle[p]; }
});
Object.assign(this.mapStyle.sources, style?.sources || {});
Object.assign(this.mapStyle.metadata, style?.metadata || {});
this.mapStyle.layers = this.mapStyle.layers.concat(style?.layers || []);
});
})
.catch(e => console.error(e));
}
/**
* Get the defaults fetch options to pass during API calls
* @memberOf Panoramax.utils.API#
* @param {boolean} [noTimeout=false] Set to true to avoid default timeout
* @private
* @returns {object} The fetch options
*/
_getFetchOptions(noTimeout=false) {
return Object.assign({
signal: noTimeout ? undefined : AbortSignal.timeout(15000)
}, this._fetchOpts);
}
/**
* Get the RequestTransformFunction for MapLibre to handle fetch options
* @memberOf Panoramax.utils.API#
* @private
* @returns {function} The RequestTransformFunction
*/
_getMapRequestTransform() {
const fetchOpts = this._getFetchOptions();
delete fetchOpts.signal;
// Only if tiles endpoint is enabled and fetch options set
if(Object.keys(fetchOpts).length > 0) {
return (url) => {
// As MapLibre will use this function for all its calls
// We must make sure fetch options are sent only for
// the STAC API calls, particularly the tiles endpoint
if(url.startsWith(this._endpoint)) {
return {
url,
...fetchOpts
};
}
};
}
}
/**
* Get the withCredentials function for PhotoSphereViewer to handle fetch credentials parameter.
* @memberOf Panoramax.utils.API#
* @private
* @returns {function} The withCredentials function
*/
_getPSVWithCredentials() {
const fetchOpts = this._getFetchOptions();
if(fetchOpts.credentials === "include") {
return (url) => url.startsWith(this._endpoint);
}
else {
return undefined;
}
}
/**
* Get sequence GeoJSON representation
* @memberOf Panoramax.utils.API#
* @param {string} seqId The sequence ID
* @param {string} [next] The next link URL (only for internals)
* @param {object} [data] The previous dataset (only for internals)
* @returns {Promise}
* @fulfil {object} Sequence GeoJSON
* @reject {Error} If API is not ready or for any network issue
*/
async getSequenceItems(seqId, next = null, data = null) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
try {
API.isIdValid(seqId);
return fetch(
next || `${this._endpoints.collections}/${seqId}/items`,
this._getFetchOptions(true)
)
.then(res => res.json())
.then(res => {
// Merge previous data with current page
let nextData = res;
if(data) { nextData.features = data.features.concat(nextData.features); }
// Handle pagination for next link
const nextLink = res.links.find(l => l.rel === "next");
if(nextLink) { return this.getSequenceItems(seqId, nextLink.href, nextData); }
else { return nextData; }
});
}
catch(e) {
return Promise.reject(e);
}
}
/**
* Get full URL for listing pictures around a specific location
* @memberOf Panoramax.utils.API#
* @param {number} lat Latitude
* @param {number} lon Longitude
* @param {number} [factor] The radius to search around (in degrees)
* @param {number} [limit] Max amount of pictures to retrieve
* @param {string} [seqId] The sequence ID to filter on (by default, no filter)
* @returns {string} The corresponding URL
*/
getPicturesAroundCoordinatesUrl(lat, lon, factor = 0.0005, limit, seqId) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(isNaN(parseFloat(lat)) || isNaN(parseFloat(lon))) {
throw new Error("lat and lon parameters should be valid numbers");
}
const bbox = [ lon - factor, lat - factor, lon + factor, lat + factor ].map(d => d.toFixed(4)).join(",");
const lim = limit ? `&limit=${limit}` : "";
const seq = seqId ? `&collections=${seqId}`: "";
return `${this._endpoints.search}?bbox=${bbox}${lim}${seq}`;
}
/**
* Get list of pictures around a specific location
* @memberOf Panoramax.utils.API#
* @param {number} lat Latitude
* @param {number} lon Longitude
* @param {number} [factor] The radius to search around (in degrees)
* @param {number} [limit] Max amount of pictures to retrieve
* @param {string} [seqId] The sequence ID to filter on (by default, no filter)
* @returns {Promise}
* @fulfil {object} The GeoJSON feature collection
*/
getPicturesAroundCoordinates(lat, lon, factor, limit, seqId) {
return fetch(this.getPicturesAroundCoordinatesUrl(lat, lon, factor, limit, seqId), this._getFetchOptions())
.then(res => res.json());
}
/**
* Get full URL for retrieving a specific picture metadata
* @memberOf Panoramax.utils.API#
* @param {string} picId The picture unique identifier
* @param {string} [seqId] The sequence ID
* @returns {string} The corresponding URL
* @throws {Error} If API is not ready
*/
getPictureMetadataUrl(picId, seqId) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(API.isIdValid(picId)) {
if(seqId) { return `${this._endpoints.collections}/${seqId}/items/${picId}`; }
else { return `${this._endpoints.search}?ids=${picId}`; }
}
}
/**
* Get JSON style for general vector tiles
* @memberOf Panoramax.utils.API#
* @return {Promise|object} Promise if first load, MapLibre JSON style otherwise
* @fulfil {object} The MapLibre JSON style
* @reject {Error} If API is not ready, or no style defined.
*/
getMapStyle() {
if(this.isReady()) { return this.mapStyle; }
let res;
// Directly available style
if(this._endpoints.style) {
res = this._endpoints.style;
}
// Vector tiles URL, embed in a minimal JSON style
else if(this._endpoints.tiles) {
res = {
"version": 8,
"sources": {
"geovisio": {
"type": "vector",
"tiles": [ this._endpoints.tiles ],
"minzoom": 0,
"maxzoom": TILES_PICTURES_ZOOM
}
}
};
}
// No endpoints : try fallback for GeoVisio API <= 2.0.1
else {
res = fetch(`${this._endpoint}/map/14/0/0.mvt`, this._getFetchOptions()).then(() => {
this._endpoints.tiles = `${this._endpoint}/map/{z}/{x}/{y}.mvt`;
console.log("Using fallback endpoint for vector tiles");
return this.getMapStyle();
}).catch(e => {
console.error(e);
return Promise.reject(new Error("API doesn't offer a vector tiles endpoint"));
});
}
// Call fetch if URL
if(typeof res === "string") {
return fetch(res, this._getFetchOptions()).then(res => res.json());
}
// Send JSON style directly
else {
return Promise.resolve(res);
}
}
/**
* Get JSON style for specific-user vector tiles
* @memberOf Panoramax.utils.API#
* @param {string} userId The user UUID
* @param {boolean} [skipReadyCheck=false] Skip check for API readyness
* @return {Promise}
* @fulfil {object} The MapLibre JSON style
* @reject {Error} If API is not ready, or no style defined.
*/
getUserMapStyle(userId, skipReadyCheck = false) {
if(!skipReadyCheck && !this.isReady()) { return Promise.reject(new Error("API is not ready to use")); }
if(!userId) { return Promise.reject(new Error("Parameter userId is empty")); }
let res;
// Directly available style
if(this._endpoints.user_style) {
res = this._endpoints.user_style.replace(/\{userId\}/g, userId);
}
// Vector tiles URL, embed in a minimal JSON style
else if(this._endpoints.user_tiles) {
res = {
"version": 8,
"sources": {
[`geovisio_${userId}`]: {
"type": "vector",
"tiles": [ this._endpoints.user_tiles.replace(/\{userId\}/g, userId) ],
"minzoom": 0,
"maxzoom": TILES_PICTURES_ZOOM
}
}
};
}
if(!res) {
return Promise.reject(new Error("API doesn't offer map style for specific user"));
}
// Call fetch if URL
else if(typeof res === "string") {
return fetch(res, this._getFetchOptions()).then(res => res.json());
}
// Send JSON style directly
else {
return Promise.resolve(res);
}
}
/**
* Find the thumbnail URL for a given picture
* @memberOf Panoramax.utils.API#
* @param {object} picture The picture GeoJSON feature
* @returns {string} The thumbnail URL, or null if not found
* @throws {Error} If API is not ready
* @private
*/
findThumbnailInPictureFeature(picture) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(!picture || !picture.assets) { return null; }
let visualFallback = null;
for(let a of Object.values(picture.assets)) {
if(a.roles.includes("thumbnail") && a.type == "image/jpeg" && API.isValidHttpUrl(a.href)) {
return a.href;
}
else if(a.roles.includes("visual") && a.type == "image/jpeg" && API.isValidHttpUrl(a.href)) {
visualFallback = a.href;
}
}
return visualFallback;
}
/**
* Get a picture thumbnail URL for a given sequence
* @memberOf Panoramax.utils.API#
* @param {string} seqId The sequence ID
* @param {object} [seq] The sequence metadata (with links) if already loaded
* @returns {Promise}
* @fulfil {string|null} Promise resolving on the picture thumbnail URL, or null if not found
* @throws {Error} If API is not ready
*/
getPictureThumbnailURLForSequence(seqId, seq) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
// Look for a dedicated endpoint in API
if(this._endpoints.collection_preview) {
return Promise.resolve(this._endpoints.collection_preview.replace("{id}", seqId));
}
// Check if a preview link exists in sequence metadata
if(seq && Array.isArray(seq.links) && seq.links.length > 0) {
let preview = seq.links.find(l => l.rel === "preview" && l.type === "image/jpeg");
if(preview && API.isValidHttpUrl(preview.href)) {
return Promise.resolve(preview.href);
}
}
// Otherwise, search for a single picture in collection
const url = `${this._endpoints.search}?limit=1&collections=${seqId}`;
return fetch(url, this._getFetchOptions())
.then(res => res.json())
.then(res => {
if(!Array.isArray(res.features) || res.features.length == 0) {
return null;
}
return this.findThumbnailInPictureFeature(res.features.pop());
});
}
/**
* Get thumbnail URL for a specific picture
* @memberOf Panoramax.utils.API#
* @param {string} picId The picture unique identifier
* @param {string} [seqId] The sequence ID
* @returns {Promise}
* @fulfil {string|null} The corresponding URL on resolve, or null if no thumbnail could be found
* @throws {Error} If API is not ready
*/
getPictureThumbnailURL(picId, seqId) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(!picId) { return Promise.resolve(null); }
// Look for a dedicated endpoint in API
if(this._endpoints.item_preview) {
return Promise.resolve(this._endpoints.item_preview.replace("{id}", picId));
}
// Pic + sequence IDs defined -> use direct item metadata
if(picId && seqId) {
return fetch(`${this._endpoints.collections}/${seqId}/items/${picId}`, this._getFetchOptions())
.then(res => res.json())
.then(picture => {
return picture ? this.findThumbnailInPictureFeature(picture) : null;
});
}
// Picture ID only -> use search as fallback
return fetch(`${this._endpoints.search}?ids=${picId}`, this._getFetchOptions())
.then(res => res.json())
.then(res => {
if(!res || !Array.isArray(res.features) || res.features.length == 0) { return null; }
return this.findThumbnailInPictureFeature(res.features.pop());
});
}
/**
* Get the RSS feed URL with map parameters (if map is enabled)
* @memberOf Panoramax.utils.API#
* @param {LngLatBounds} [bbox] The map current bounding box, or null if not available
* @returns {string|null} The URL, or null if no RSS feed is available
* @throws {Error} If API is not ready
*/
getRSSURL(bbox) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(this._endpoints.rss) {
let url = this._endpoints.rss;
if(bbox) {
url += url.includes("?") ? "&": "?";
url += "bbox=" + [bbox.getWest(), bbox.getSouth(), bbox.getEast(), bbox.getNorth()].join(",");
}
return url;
}
else {
return null;
}
}
/**
* Get full URL for retrieving a specific sequence metadata
* @memberOf Panoramax.utils.API#
* @param {string} seqId The sequence ID
* @returns {string} The corresponding URL
* @throws {Error} If API is not ready
*/
getSequenceMetadataUrl(seqId) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
return `${this._endpoints.collections}/${seqId}`;
}
/**
* Get available data bounding box
* @memberOf Panoramax.utils.API#
* @returns {LngLatBoundsLike} The bounding box or null if not available
* @throws {Error} If API is not ready
*/
getDataBbox() {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
return this._dataBbox;
}
/**
* Look for user ID based on user name query
* @memberOf Panoramax.utils.API#
* @param {string} query The user name to look for
* @returns {Promise}
* @fulfil {object|null} List of potential users
* @throws {Error} If API is not ready or user search not available
*/
searchUsers(query) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(!this._endpoints.user_search) { throw new Error("User search is not available"); }
return fetch(`${this._endpoints.user_search}?q=${query}`, this._getFetchOptions())
.then(res => res.json())
.then(res => {
return res?.features || null;
});
}
/**
* Get user name based on its ID
* @memberOf Panoramax.utils.API#
* @param {string} userId The user UUID
* @returns {Promise}
* @throws {Error} If API is not ready
* @fulfil {string|null} The user name (or null if not found)
*/
getUserName(userId) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(!this._endpoints.user_search) { throw new Error("User search is not available"); }
return fetch(this._endpoints.user_search.replace(/\/search$/, `/${userId}`), this._getFetchOptions())
.then(res => res.json())
.then(res => {
return res?.label || res?.name || null;
});
}
/**
* Send a report to API
* @memberOf Panoramax.utils.API#
* @param {object} data The input form data
* @returns {Promise}
* @fulfil {object} The JSON API response
*/
sendReport(data) {
if(!this.isReady()) { throw new Error("API is not ready to use"); }
if(!this._endpoints.report) { throw new Error("Report sending is not available"); }
const opts = {
...this._getFetchOptions(),
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
};
return fetch(this._endpoints.report, opts)
.then(async res => {
if(res.status >= 400) {
let txt = await res.text();
try {
txt = JSON.parse(txt)["message"];
}
catch(e) {} // eslint-disable-line no-empty
return Promise.reject(txt);
}
return res.json();
});
}
/**
* Checks URL string validity
* @memberOf Panoramax.utils.API
* @param {string} str The URL to check
* @returns {boolean} True if valid
*/
static isValidHttpUrl(str) {
let url;
try {
url = new URL(str);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
/**
* Checks picture or sequence ID validity
* @memberOf Panoramax.utils.API
* @param {string} id The ID to check
* @returns {boolean} True if valid
* @throws {Error} If not valid
*/
static isIdValid(id) {
if(isNullId(id)) {
throw new Error("id should be a valid picture unique identifier");
}
return true;
}
}