@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
365 lines (329 loc) ⢠11.5 kB
JavaScript
import { LitElement, html } from "lit";
import API from "../../utils/API";
import { getTranslations } from "../../utils/i18n";
import { MapTiles } from "../../utils/services";
import { createWebComp } from "../../utils/widgets";
import { isInIframe, isInternetFast } from "../../utils/utils";
import JSON5 from "json5";
import PACKAGE_JSON from "../../../package.json";
import "@fontsource/atkinson-hyperlegible-next";
import "./Basic.css";
/**
* Event for overlaying menu opening
* @event Panoramax.components.core.Basic#menu-opened
* @type {CustomEvent}
* @property {Element} detail.menu The opened menu
*/
/**
* Basic core component is a basic container for common functions through all core components.
* It is not intended to be used directly, it's only to be extended by other core components.
* @class Panoramax.components.core.Basic
* @extends [lit.LitElement](https://lit.dev/docs/api/LitElement/)
* @fires Panoramax.components.core.Basic#select
* @fires Panoramax.components.core.Basic#ready
* @fires Panoramax.components.core.Basic#broken
* @fires Panoramax.components.core.Basic#menu-opened
* @property {Panoramax.components.ui.Loader} loader The loader screen
* @property {Panoramax.utils.API} api The API manager
*/
export default class Basic extends LitElement {
/**
* Component properties.
* @memberof Panoramax.components.core.Basic#
* @type {Object}
* @mixin
* @property {string} endpoint URL to API to use (must be a [STAC API](https://github.com/radiantearth/stac-api-spec/blob/main/overview.md))
* @property {string} [picture] The picture ID to display
* @property {string} [sequence] The sequence ID of the picture displayed
* @property {object} [fetch-options] 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))
* @property {string[]} [users=[geovisio]] List of users IDs to use for map display (defaults to general map, identified as "geovisio")
* @property {string|object} [map-style] The map's MapLibre style. This can be an a JSON object conforming to the schema described in the [MapLibre Style Specification](https://maplibre.org/maplibre-style-spec/), or a URL string pointing to one. Defaults to OSM vector tiles.
* @property {string} [lang] To override language used for labels. Defaults to using user's preferred languages.
*/
static properties = {
picture: {type: String, reflect: true},
sequence: {type: String, reflect: true},
"fetch-options": {converter: Basic.GetJSONConverter()},
users: {type: Array, reflect: true},
"map-style": {type: String},
lang: {type: String},
endpoint: {type: String},
};
constructor(testing = false) {
super();
// Some defaults
this.users = ["geovisio"];
this["map-style"] = this.getAttribute("map-style") || MapTiles();
this.lang = this.getAttribute("lang") || null;
this.endpoint = this.getAttribute("endpoint") || null; // No default
this.picture = this.getAttribute("picture") || null;
this.sequence = this.getAttribute("sequence") || null;
// Display version in logs
console.info(`š· Panoramax ${this.getClassName()} - Version ${PACKAGE_JSON.version} (${__COMMIT_HASH__})
š Issues can be reported at ${PACKAGE_JSON.repository.url}`);
if(testing) { return; }
// Internet speed check
this._isInternetFast = null;
isInternetFast().then(isFast => this._isInternetFast = isFast);
}
connectedCallback() {
super.connectedCallback();
// Translations
this._t = getTranslations(this.lang);
// Show loader
this.loader = createWebComp("pnx-loader", {_parent: this, "no-label": isInIframe() });
if(
!(this._loadsAPI && this.endpoint && this._loadsAPI === this.endpoint)
&& !(this.api && this.api._endpoint === this.endpoint)
&& this.endpoint
) {
if(this._loadsAPI || this.api) {
delete this.api;
delete this._loadsAPI;
}
this._setupAPI();
}
// Warn for outdate attributes
Object
.entries({ map: "map-options", psv: "psv-options", fetchOptions: "fetch-options", mapstyle: "map-style"})
.forEach(([k, v]) => {
if(this.getAttribute(k)) {
console.error(`Component attribute "${k}" has been renamed into "${v}". Old attribute "${k}" is ignored.`);
}
});
}
/**
* Creates API and wait for initial loading
* @private
*/
_setupAPI() {
// Loader init
this.loader = this.loader || createWebComp("pnx-loader", {_parent: this});
if(!this.endpoint) {
console.warn("No endpoint is defined");
return;
}
this._loadsAPI = this.endpoint;
let myLoadAPI = this.endpoint;
// Check if mapstyle is not a unparsed JSON
try {
this["map-style"] = JSON.parse(this["map-style"]);
} catch(e) { /* empty */ }
// API init
try {
this.api = new API(this.endpoint, {
users: this.users,
fetch: this["fetch-options"],
style: this["map-style"],
});
this.api.onceReady()
.then(() => {
if(myLoadAPI != this._loadsAPI || !this.api) { return; }
let unavailable = this.api.getUnavailableFeatures();
let available = this.api.getAvailableFeatures();
available = unavailable.length === 0 ? "ā
All features available" : "ā
Available features: "+available.join(", ");
unavailable = unavailable.length === 0 ? "" : "š« Unavailable features: "+unavailable.join(", ");
console.info(`š Connected to API "${this.api._metadata.name}" (${this.api._endpoint})
ā¹ļø API runs STAC ${this.api._metadata.stac_version} ${this.api._metadata.geovisio_version ? "& GeoVisio "+this.api._metadata.geovisio_version : ""}
${available}
${unavailable}
`.trim());
})
.catch(e => this.loader.dismiss(e, this._t.pnx.error_api))
.finally(() => delete this._loadsAPI);
}
catch(e) {
delete this._loadsAPI;
if(this.loader?.dismiss) {
this.loader.dismiss(e, this._t.pnx.error_api);
}
else {
console.error(e);
}
}
}
/**
* Waits for component to have its first loading done.
*
* Each inheriting class must override this method.
* @memberof Panoramax.components.core.Basic#
* @returns {Promise}
* @fulfil {null} When initialization is complete.
* @reject {string} Error message
*/
onceReady() {
throw new Error("You must override this method on sub-class");
}
/**
* Waits for initial API setup.
* @memberof Panoramax.components.core.Basic#
* @returns {Promise}
* @fulfil {null} When API is ready.
* @reject {string} Error message
*/
onceAPIReady() {
if(this.api) {
return this.api.onceReady();
}
else {
return new Promise(resolve => setTimeout(resolve, 100)).then(this.onceAPIReady.bind(this));
}
}
/** @private */
createRenderRoot() {
return this;
}
/** @private */
attributeChangedCallback(name, _old, value) {
super.attributeChangedCallback(name, _old, value);
if(name === "endpoint") {
if(
!(this._loadsAPI && value && this._loadsAPI === value)
&& !(this.api && this.api._endpoint === value)
&& value
) {
if(this._loadsAPI || this.api) {
delete this.api;
delete this._loadsAPI;
}
this._setupAPI();
}
}
if(["picture", "sequence"].includes(name)) {
let seqId, picId, prevSeqId, prevPicId;
if(name === "picture") {
seqId = this.sequence;
prevSeqId = this.sequence;
picId = value;
prevPicId = _old;
}
else {
seqId = value;
prevSeqId = _old;
picId = this.picture;
prevPicId = this.picture;
}
/**
* Event for sequence/picture selection
* @event Panoramax.components.core.Basic#select
* @type {CustomEvent}
* @property {string} detail.seqId The selected sequence ID
* @property {string} detail.picId The selected picture ID (or null if not a precise picture clicked)
* @property {string} [detail.prevSeqId] The previously selected sequence ID (or null if none)
* @property {string} [detail.prevPicId] The previously selected picture ID (or null if none)
*/
this.dispatchEvent(new CustomEvent("select", {
bubbles: true,
composed: true,
detail: {
seqId,
picId,
prevSeqId,
prevPicId,
}
}));
}
}
/**
* This allows to retrieve an always correct class name.
* This is crap, but avoids issues with Webpack & so on.
*
* Each inheriting class must override this method.
* @returns {string} The class name (for example "Basic")
* @memberof Panoramax.components.core.Basic#
*/
getClassName() {
return "Basic";
}
/**
* Change the currently picture and/or sequence.
* Calling the method without parameters unselects.
* @param {string} [seqId] The sequence UUID
* @param {string} [picId] The picture UUID
* @param {boolean} [force=false] Force select even if already selected
* @memberof Panoramax.components.core.Basic#
*/
select(seqId = null, picId = null, force = false) {
if(force) {
this.picture = null;
this.sequence = null;
}
this.picture = picId;
this.sequence = seqId;
}
/**
* Is the view running in a small container (small embed or smartphone)
* @returns {boolean} True if container is small
* @memberof Panoramax.components.core.Basic#
*/
isWidthSmall() {
return this?.offsetWidth < 576;
}
/**
* Is the view running in a small-height container (small embed or smartphone)
* @returns {boolean} True if container height is small
* @memberof Panoramax.components.core.Basic#
*/
isHeightSmall() {
return this?.offsetHeight < 400;
}
/** @private */
render() {
return html`<p>Should not be used directly, use Viewer/CoverageMap/Editor instead</p>`;
}
/**
* List names of sub-components (like loader, api, map, psv) available in this component.
* @returns {string[]} Sub-components names.
* @memberof Panoramax.components.core.Basic#
*/
getSubComponentsNames() {
return ["loader", "api"];
}
/**
* Listen to events from this components or one of its sub-components.
*
* For example, you can listen to `map` events using prefix `map:`.
*
* ```js
* me.addEventListener("map:move", doSomething);
* ```
* @param {string} type The event type to listen for
* @param {function} listener The event handler
* @param {object} [options] [Any original addEventListener available options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options)
* @memberof Panoramax.components.core.Basic#
*/
addEventListener(type, listener, options) {
// Check if listener is for sub-component
let prefix = type.split(":").shift();
if(prefix && this.getSubComponentsNames().includes(prefix)) {
const subType = type.substring(prefix.length+1);
// Add directly if available
if(this[prefix]?.addEventListener) {
this[prefix].addEventListener(subType, listener, options);
}
// Wait for addEventListener to be available
else {
setTimeout(() => this.addEventListener(type, listener, options), 50);
}
}
// Otherwise, reuse classic function
else {
super.addEventListener(type, listener, options);
}
}
/** @private */
static GetJSONConverter() {
return {
fromAttribute: (value) => {
if(value === null || value === "") { return null; }
else if(typeof value === "object" || Array.isArray(value)) { return value; }
else { return JSON5.parse(value); }
},
toAttribute: (value) => {
if(value === null || value === "") { return ""; }
else if(typeof value === "string") { return value; }
else { return JSON5.stringify(value); }
}
};
}
}