UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

550 lines (488 loc) 17.2 kB
/* eslint-disable import/no-unused-modules */ /* eslint-disable no-unused-vars */ import "./PhotoViewer.css"; import { SYSTEM as PSSystem, DEFAULTS as PSDefaults } from "@photo-sphere-viewer/core"; import URLHandler from "../../utils/URLHandler"; import Basic from "./Basic"; import Photo, { PSV_DEFAULT_ZOOM, PSV_ANIM_DURATION } from "../ui/Photo"; import { createWebComp } from "../../utils/widgets"; import { isNullId, isInIframe } from "../../utils/utils"; import { default as InitParameters, alterPSVState, alterMapState, alterPhotoViewerState } from "../../utils/InitParameters"; import PresetManager from "../../utils/PresetsManager"; export const PSV_ZOOM_DELTA = 20; const PSV_MOVE_DELTA = Math.PI / 6; export const KEYBOARD_SKIP_FOCUS_WIDGETS = ["pnx-mini", "pnx-widget-player", "pnx-widget-zoom"]; /** * Photo Viewer is a component showing pictures (without any map). * * This component has a [CorneredGrid](#Panoramax.components.layout.CorneredGrid) layout, you can use directly any slot element to pass custom widgets. * * If you need a viewer with map, checkout [Viewer component](#Panoramax.components.core.Viewer). * * Make sure to set width/height through CSS for proper display. * @class Panoramax.components.core.PhotoViewer * @element pnx-photo-viewer * @extends Panoramax.components.core.Basic * @property {Panoramax.components.ui.Loader} loader The loader screen * @property {Panoramax.utils.API} api The API manager * @property {Panoramax.components.ui.Photo} psv The Photo Sphere Viewer component itself * @property {Panoramax.components.layout.CorneredGrid} grid The grid layout manager * @property {Panoramax.components.ui.Popup} popup The popup container * @property {Panoramax.utils.URLHandler} urlHandler The URL query parameters manager * @property {Panoramax.utils.PresetsManager} presetsManager The semantics presets manager * @fires Panoramax.components.core.Basic#select * @fires Panoramax.components.core.Basic#ready * @fires Panoramax.components.core.Basic#broken * @slot `top-left` The top-left corner * @slot `top` The top middle corner * @slot `top-right` The top-right corner * @slot `bottom-left` The bottom-left corner * @slot `bottom` The bottom middle corner * @slot `bottom-right` The bottom-right corner * @slot `editors` External links to map editors, or any tool that may be helpful. Defaults to OSM tools (iD & JOSM). * @example * ```html * <!-- Basic example --> * <pnx-photo-viewer * endpoint="https://panoramax.openstreetmap.fr/" * style="width: 300px; height: 250px" * /> * * <!-- With slotted widgets --> * <pnx-photo-viewer * endpoint="https://panoramax.openstreetmap.fr/" * style="width: 300px; height: 250px" * > * <p slot="top-right">My custom text</p> * <p slot="editors"><a href="https://my.own.tool/">Edit in my own tool</a></p> * </pnx-photo-viewer> * * <!-- With only your custom widgets --> * <pnx-photo-viewer * endpoint="https://panoramax.openstreetmap.fr/" * style="width: 300px; height: 250px" * widgets="false" * > * <p slot="top-right">My custom text</p> * </pnx-photo-viewer> * ``` */ export default class PhotoViewer extends Basic { /** * Component properties. All of [Basic properties](#Panoramax.components.core.Basic+properties) are available as well. * @memberof Panoramax.components.core.PhotoViewer# * @mixes Panoramax.components.core.Basic#properties * @type {Object} * @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 {object} [psv] [Any option to pass to Photo component](#Panoramax.components.ui.Photo) as an object.<br />Example: `psv="{'transitionDuration': 500, 'picturesNavigation': 'pic'}"` * @property {string} [widgets=true] Use default set of widgets ? Set to false to avoid any widget to show up, and use slots to populate as you like. * @property {string} [picture] The picture ID to display * @property {string} [sequence] The sequence ID of the picture displayed * @property {object} [fetchOptions] 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} [lang] To override language used for labels. Defaults to using user's preferred languages. * @property {string} [url-parameters=true] Should the component add and update URL query parameters to save viewer state ? * @property {string} [keyboard-shortcuts=true] Should keyboard shortcuts be enabled ? Set to "false" to fully disable any keyboard shortcuts. */ static properties = { psv: {converter: Basic.GetJSONConverter()}, widgets: {type: String}, "url-parameters": {type: String}, "keyboard-shortcuts": {type: String}, ...Basic.properties }; constructor() { super(); // Defaults this.psv = {}; this["url-parameters"] = this.getAttribute("url-parameters") || true; this["keyboard-shortcuts"] = this.getAttribute("keyboard-shortcuts") || true; this.widgets = this.getAttribute("widgets") || "true"; // Init DOM containers this.grid = createWebComp("pnx-cornered-grid"); this.psvContainer = document.createElement("div"); this.psvContainer.setAttribute("slot", "bg"); this.grid.appendChild(this.psvContainer); this.popup = createWebComp("pnx-popup", {_parent: this, onclose: this._onPopupClose.bind(this)}); } /** @private */ _createInitParamsHandler() { this._initParams = new InitParameters( InitParameters.GetComponentProperties(PhotoViewer, this), Object.assign({}, this.urlHandler?.currentURLParams(), this.urlHandler?.currentURLParams(true)), {}, ); } /** @private */ _initWidgets() { if(this._initParams.getParentPostInit().widgets !== "false") { if(!isInIframe()) { this.grid.appendChild(createWebComp("pnx-widget-player", { slot: "top", _parent: this, class: "pnx-only-psv pnx-print-hidden", size: this.isHeightSmall() ? "md": "xl", })); this.grid.appendChild(createWebComp("pnx-annotations-switch", { slot: "top", _parent: this, class: "pnx-only-psv pnx-print-hidden", size: this.isHeightSmall() ? "md": "xl", })); } if(isInIframe()) { this.legend = createWebComp("pnx-widget-legend", { slot: "bottom-right", light: true, _parent: this, focus: this._initParams.getParentPostInit().focus, picture: this._initParams.getParentPostInit().picture, }); this.grid.appendChild(this.legend); } else if(!this.isWidthSmall()) { this.legend = createWebComp("pnx-widget-legend", { slot: !this.isWidthSmall() ? "top-left" : undefined, _parent: this, focus: this._initParams.getParentPostInit().focus, picture: this._initParams.getParentPostInit().picture, }); this.grid.appendChild(createWebComp("pnx-widget-zoom", { slot: "bottom-right", class: "pnx-print-hidden", _parent: this })); this.grid.appendChild(this.legend); } else { this.legend = createWebComp("pnx-picture-legend", { _parent: this }); this.bottomDrawer = createWebComp("pnx-bottom-drawer", { slot: "bottom", _parent: this, class: this._initParams.getParentPostInit().picture ? undefined: "pnx-hidden", }); this.bottomDrawer.appendChild(this.legend); this.grid.appendChild(this.bottomDrawer); this.addEventListener("select", e => { if(isNullId(e.detail.picId)) { this.bottomDrawer.classList.add("pnx-hidden"); } else { this.bottomDrawer.classList.remove("pnx-hidden"); } }); } } } /** @private */ connectedCallback() { super.connectedCallback(); this.presetsManager = new PresetManager(this.lang); if(this["url-parameters"] && this["url-parameters"] !== "false") { this.urlHandler = new URLHandler(this); this.onceReady().then(() => { this.urlHandler.listenToChanges(); this.urlHandler._onParentChange(); }); } this.onceAPIReady().then(this._postAPIInit.bind(this)); } /** @private */ disconnectedCallback() { super.disconnectedCallback(); this.urlHandler?.destroy(); this.psv?.destroy(); } /** @private */ firstUpdated() { super.firstUpdated(); this._moveChildToGrid(); } getClassName() { return "PhotoViewer"; } /** * Waits for PhotoViewer to be completely ready (map & PSV loaded, first picture also if one is wanted) * @returns {Promise} When viewer is ready * @memberof Panoramax.components.core.PhotoViewer# */ onceReady() { return this.oncePSVReady().then(() => { if(this._initParams.getParentPostInit().picture && !this.psv.getPictureMetadata()) { return this.onceFirstPicLoaded(); } else { return Promise.resolve(); } }); } /** @private */ render() { return [this.loader, this.grid, this.popup]; } getSubComponentsNames() { return super.getSubComponentsNames().concat(["psv", "grid", "popup", "urlHandler"]); } /** * Waiting for Photo Sphere Viewer to be available. * @returns {Promise} When PSV is ready to use * @memberof Panoramax.components.core.PhotoViewer# */ oncePSVReady() { let waiter; return new Promise(resolve => { waiter = setInterval(() => { if(this.psv && typeof this.psv === "object") { if(this.psv.container) { clearInterval(waiter); resolve(); } else if(this.psv.addEventListener) { this.psv.addEventListener("ready", () => { clearInterval(waiter); resolve(); }, {once: true}); } } }, 250); }); } /** * Waits for first picture to display on PSV. * @returns {Promise} * @fulfil {undefined} When picture is shown * @memberof Panoramax.components.core.PhotoViewer# */ onceFirstPicLoaded() { return this.oncePSVReady().then(() => { if(this.psv.getPictureMetadata()) { return Promise.resolve(); } else { return new Promise(resolve => { this.psv.addEventListener("picture-loaded", resolve, {once: true}); }); } }); } /** @private */ async _postAPIInit() { this.loader.setAttribute("value", 30); this._createInitParamsHandler(); const myPostInitParams = this._initParams.getParentPostInit(); this._initPSV(); this._initWidgets(); alterPhotoViewerState(this, myPostInitParams); if(myPostInitParams.keyboardShortcuts) { this._handleKeyboardManagement(); } if(myPostInitParams.picture) { this.psv.addEventListener("picture-loaded", () => this.loader.dismiss(), {once: true}); } else { this.loader.dismiss(); } } /** @private */ _initPSV() { try { this.psv = new Photo(this, this.psvContainer, { shouldGoFast: this._psvShouldGoFast.bind(this), keyboard: "always", keyboardActions: { ...PSDefaults.keyboardActions, "8": "ROTATE_UP", "2": "ROTATE_DOWN", "4": "ROTATE_LEFT", "6": "ROTATE_RIGHT", "PageUp": () => this.psv.goToNextPicture(), "9": () => this.psv.goToNextPicture(), "PageDown": () => this.psv.goToPrevPicture(), "3": () => this.psv.goToPrevPicture(), "5": () => this.moveCenter(), "*": () => this.moveCenter(), "Home": () => this._toggleFocus(), "7": () => this._toggleFocus(), "End": () => this.mini.toggleAttribute("collapsed"), "1": () => this.mini.toggleAttribute("collapsed"), " ": () => this.psv.toggleSequencePlaying(), "0": () => this.psv.toggleSequencePlaying(), }, ...this._initParams.getPSVInit() }); this.oncePSVReady().then(() => { this.loader.setAttribute("value", 50); alterPSVState(this.psv, this._initParams.getPSVPostInit()); }); // Show class when PSV is playing sequence this.psv.addEventListener("sequence-playing", () => this.classList.add("pnx-playing")); this.psv.addEventListener("sequence-stopped", () => this.classList.remove("pnx-playing")); } catch(e) { let err = !PSSystem.isWebGLSupported ? this._t.pnx.error_webgl : this._t.pnx.error_psv; this.loader.dismiss(e, err); } } /** @private */ _handleKeyboardManagement() { // Switchers const keytonone = () => this.psv.stopKeyboardControl(); const keytopsv = () => this.psv.startKeyboardControl(); // Popup this.popup.addEventListener("open", keytonone); this.popup.addEventListener("close", keytopsv); this.psv.addEventListener("click", keytopsv); // Widgets for(let cn of this.grid.childNodes) { if( cn.getAttribute("slot") !== "bg" && !KEYBOARD_SKIP_FOCUS_WIDGETS.includes(cn.tagName.toLowerCase()) ) { cn.addEventListener("focusin", keytonone); cn.addEventListener("focusout", () => { if(this.popup.getAttribute("visible") === null) { keytopsv(); } }); } } } /** * Given context, should tiles be loaded in PSV. * @private */ _psvShouldGoFast() { return (this.psv._sequencePlaying && this.psv.getTransitionDuration() < 1000); } /** @private */ _moveChildToGrid() { const slotContent = Array.from(this.querySelectorAll("[slot]")); slotContent.forEach(n => { // Add parent + translation for our components if(n.tagName?.toLowerCase().startsWith("pnx-")) { n._parent = this; n._t = this._t; } // Editors slot -> legend if(n.getAttribute("slot") === "editors") { this.onceReady().then(() => this.legend?.appendChild(n)); } // Add to grid else else { this.grid.appendChild(n); } }); } /** * Change full-page popup visibility and content * @memberof Panoramax.components.core.PhotoViewer# * @param {boolean} visible True to make it appear * @param {string|Element[]} [content] The new popup content */ setPopup(visible, content = null) { if(visible) { this.popup.setAttribute("visible", ""); } else { this.popup.removeAttribute("visible"); } this.popup.innerHTML = ""; if(typeof content === "string") { this.popup.innerHTML = content; } else if(Array.isArray(content)) { content.forEach(c => this.popup.appendChild(c)); } } /** @private */ _onPopupClose() { this.dispatchEvent(new CustomEvent("focus-changed", { detail: { focus: this.map && this.isMapWide() ? "map" : "pic" } })); } /** @private */ _showQualityScoreDoc() { this.setPopup(true, [createWebComp("pnx-quality-score-doc", {_t: this._t})]); } /** @private */ _showReportForm() { if(!this.psv.getPictureMetadata()) { throw new Error("No picture currently selected"); } this.setPopup(true, [createWebComp("pnx-report-form", {_parent: this})]); } /** @private */ _showShareOptions() { this.setPopup(true, [createWebComp("pnx-share-menu", {_parent: this})]); } /** * Move the view of main component to its center. * For map, center view on selected picture. * For picture, center view on image center. * @memberof Panoramax.components.core.PhotoViewer# */ moveCenter() { const meta = this.psv.getPictureMetadata(); if(!meta) { return; } this._psvAnimate({ speed: PSV_ANIM_DURATION, yaw: 0, pitch: 0, zoom: PSV_DEFAULT_ZOOM }); } /** * Moves the view of main component slightly to the left. * @memberof Panoramax.components.core.PhotoViewer# */ moveLeft() { this._moveToDirection("left"); } /** * Moves the view of main component slightly to the right. * @memberof Panoramax.components.core.PhotoViewer# */ moveRight() { this._moveToDirection("right"); } /** * Moves the view of main component slightly to the top. * @memberof Panoramax.components.core.PhotoViewer# */ moveUp() { this._moveToDirection("up"); } /** * Moves the view of main component slightly to the bottom. * @memberof Panoramax.components.core.PhotoViewer# */ moveDown() { this._moveToDirection("down"); } /** * Moves map or picture viewer to given direction. * @param {string} dir Direction to move to (up, left, down, right) * @private */ _moveToDirection(dir) { let pos = this.psv.getPosition(); switch(dir) { case "up": pos.pitch += PSV_MOVE_DELTA; break; case "left": pos.yaw -= PSV_MOVE_DELTA; break; case "down": pos.pitch -= PSV_MOVE_DELTA; break; case "right": pos.yaw += PSV_MOVE_DELTA; break; } this._psvAnimate({ speed: PSV_ANIM_DURATION, ...pos }); } /** * Overrided PSV animate function to ensure a single animation plays at once. * @param {object} options PSV animate options * @private */ _psvAnimate(options) { if(this._lastPsvAnim) { this._lastPsvAnim.cancel(); } this._lastPsvAnim = this.psv.animate(options); } /** * Listen to events from this components or one of its sub-components. * * For example, you can listen to `psv` events using prefix `psv:`. * * ```js * me.addEventListener("psv:picture-loading", 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.PhotoViewer# */ addEventListener(type, listener, options) { super.addEventListener(type, listener, options); } } customElements.define("pnx-photo-viewer", PhotoViewer);