UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

629 lines (560 loc) 20.9 kB
/* eslint-disable no-unused-vars */ import "./Viewer.css"; import { linkMapAndPhoto, saveMapParamsToLocalStorage, getMapParamsFromLocalStorage } from "../../utils/map"; import PhotoViewer, {KEYBOARD_SKIP_FOCUS_WIDGETS} from "./PhotoViewer"; import MapMore from "../ui/MapMore"; import { initMapKeyboardHandler, mapFiltersFormValues } from "../../utils/map"; import { isNullId, isInIframe, DISABLE_ANNOTATIONS_PARAM } from "../../utils/utils"; import { createWebComp } from "../../utils/widgets"; import { fa } from "../../utils/widgets"; import { faPanorama } from "@fortawesome/free-solid-svg-icons/faPanorama"; import { faMap } from "@fortawesome/free-solid-svg-icons/faMap"; import { querySelectorDeep } from "query-selector-shadow-dom"; import { default as InitParameters, alterMapState, alterViewerState } from "../../utils/InitParameters"; const MAP_MOVE_DELTA = 100; /** * Viewer is the main component of Panoramax JS library, showing pictures and 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 without map, checkout [Photo Viewer component](#Panoramax.components.core.PhotoViewer). * * Make sure to set width/height through CSS for proper display. * @class Panoramax.components.core.Viewer * @element pnx-viewer * @extends Panoramax.components.core.PhotoViewer * @property {Panoramax.components.ui.Loader} loader The loader screen * @property {Panoramax.utils.API} api The API manager * @property {Panoramax.components.ui.MapMore} map The MapLibre GL map itself * @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.layout.Mini} mini The reduced/collapsed map/photo component * @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 * @fires Panoramax.components.core.Viewer#focus-changed * @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-viewer * endpoint="https://panoramax.openstreetmap.fr/" * style="width: 300px; height: 250px" * /> * * <!-- With slotted widgets --> * <pnx-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-viewer> * * <!-- With only your custom widgets --> * <pnx-viewer * endpoint="https://panoramax.openstreetmap.fr/" * style="width: 300px; height: 250px" * widgets="false" * > * <p slot="top-right">My custom text</p> * </pnx-viewer> * * <!-- With map options --> * <pnx-viewer * endpoint="https://panoramax.openstreetmap.fr/" * style="width: 300px; height: 250px" * map-options="{'maxZoom': 15, 'background': 'aerial', 'raster': '...'}" * /> * ``` */ export default class Viewer extends PhotoViewer { /** * Component properties. All of [Basic properties](#Panoramax.components.core.Basic+properties) are available as well. * @memberof Panoramax.components.core.Viewer# * @mixes Panoramax.components.core.PhotoViewer#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} [map-options] An object with [any map option available in Map or MapMore class](#Panoramax.components.ui.MapMore).<br />Example: `map-options="{'background': 'aerial', 'theme': 'age'}"` * @property {object} [psv-options] [Any option to pass to Photo component](#Panoramax.components.ui.Photo) as an object.<br />Example: `psv-options="{'transitionDuration': 500, 'picturesNavigation': 'pic'}"` * @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. * @property {string} [focus=pic] The component showing up as main component (pic, map) * @property {string} [geocoder=nominatim] The geocoder engine to use (nominatim, ban, or URL to a standard [GeocodeJSON-compliant](https://github.com/geocoders/geocodejson-spec/blob/master/draft/README.md) API) * @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} [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 = { "map-options": {converter: PhotoViewer.GetJSONConverter()}, focus: {type: String, reflect: true}, geocoder: {type: String}, tabindex: {type: Number}, ...PhotoViewer.properties }; constructor() { super(); // Defaults this["map-options"] = true; this.geocoder = this.getAttribute("geocoder") || "nominatim"; // Init DOM containers this.mini = createWebComp("pnx-mini", { slot: "bottom-left", _parent: this, onexpand: this._onMiniExpand.bind(this), collapsed: isNullId(this.picture) ? true : undefined }); this.mini.addEventListener("expand", this._toggleFocus.bind(this)); this.grid.appendChild(this.mini); this.mapContainer = document.createElement("div"); this.tabindex = 0; } /** @private */ _createInitParamsHandler() { this._initParams = new InitParameters( InitParameters.GetComponentProperties(Viewer, this), Object.assign({}, this.urlHandler?.currentURLParams(), this.urlHandler?.currentURLParams(true)), { map: getMapParamsFromLocalStorage(), disableAnnotations: localStorage.getItem(DISABLE_ANNOTATIONS_PARAM) }, ); } /** @private */ _initWidgets() { if(this._initParams.getParentPostInit().widgets !== "false") { this.grid.appendChild(createWebComp("pnx-widget-zoom", { slot: this.isWidthSmall() ? "top-left" : "bottom-right", class: this.isWidthSmall() ? "pnx-only-map pnx-print-hidden" : "pnx-print-hidden", _parent: this })); 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" : "top-left", _parent: this, focus: this._initParams.getParentPostInit().focus, picture: this._initParams.getParentPostInit().picture, }); this._miniPicLegend = createWebComp("pnx-mini-picture-legend", { _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().willLoadPicture ? 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"); } }); } 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", })); this.grid.appendChild(createWebComp("pnx-widget-geosearch", { slot: this.isWidthSmall() ? "top-right" : "top-left", _parent: this, class: "pnx-only-map pnx-print-hidden", geocoder: this._initParams.getParentPostInit().geocoder, })); this.grid.appendChild(createWebComp("pnx-widget-mapfilters", { slot: this.isWidthSmall() ? "top-right" : "top-left", _parent: this, "user-search": this.api._endpoints.user_search !== null && this.api._endpoints.user_tiles !== null, "quality-score": this.map?._hasQualityScore?.() || false, class: "pnx-only-map pnx-print-hidden", })); this.grid.appendChild(createWebComp("pnx-widget-maplayers", { slot: "top-right", _parent: this, class: "pnx-only-map pnx-print-hidden" })); } } } /** @private */ disconnectedCallback() { super.disconnectedCallback(); this.map?.destroy(); } getClassName() { return "Viewer"; } getSubComponentsNames() { return super.getSubComponentsNames().concat(["mini", "map"]); } /** * Waits for Viewer to be completely ready (map & PSV loaded, first picture also if one is wanted) * @returns {Promise} When viewer is ready * @memberof Panoramax.components.core.Viewer# */ onceReady() { return Promise.all([this.oncePSVReady(), this.onceMapReady()]) .then(() => { if(this._initParams.getParentPostInit().willLoadPicture && !this.psv.getPictureMetadata()) { return this.onceFirstPicLoaded(); } else { return Promise.resolve(); } }); } /** @private */ attributeChangedCallback(name, old, value) { super.attributeChangedCallback(name, old, value); if(name === "picture") { this.legend?.setAttribute?.("picture", value); // First pic load : show map in mini component if(isNullId(old) && !isNullId(value)) { this.mini.removeAttribute("collapsed"); } // Unselect -> show map wide instead if(isNullId(value)) { if(this.map && this.isMapWide()) { this.mini.classList.add("pnx-hidden"); } else if(this.map && !this.isMapWide()) { this._setFocus("map"); } } // Check if it's not first load from seq=* URL parameter else if( isNullId(old) && this.sequence == this._initParams.getParentPostInit().sequence && !this._initParams.getParentPostInit().picture ) { this.mini.classList.remove("pnx-hidden"); this.mini.removeAttribute("collapsed"); if(this.bottomDrawer?.getAttribute?.("openness") === "closed") { this.bottomDrawer.setAttribute("openness", "half-opened"); } } // Select after none selected -> show pic wide else { this.mini.classList.remove("pnx-hidden"); if(isNullId(old)) { this._setFocus("pic"); if(this.bottomDrawer?.getAttribute?.("openness") === "closed") { this.bottomDrawer.setAttribute("openness", "half-opened"); } } } } if(name === "focus") { this._setFocus(value); } } /** * Waiting for map to be available. * @returns {Promise} When map is ready to use * @memberof Panoramax.components.core.Viewer# */ onceMapReady() { if(!this.map) { return Promise.resolve(); } let waiter; return new Promise(resolve => { waiter = setInterval(() => { if(typeof this.map === "object") { if(this.map?.loaded?.()) { clearInterval(waiter); resolve(); } else if(this.map?.once) { this.map.once("render", () => { clearInterval(waiter); resolve(); }); } } }, 250); }); } /** * Inits MapLibre GL component * * @private * @returns {Promise} Resolves when map is ready */ async _initMap() { await new Promise(resolve => { this.map = new MapMore(this, this.mapContainer, this._initParams.getMapInit()); saveMapParamsToLocalStorage(this.map); this.map.once("users-changed", () => { this.loader.setAttribute("value", 75); resolve(); }); }); await alterMapState(this.map, this._initParams.getMapPostInit()); initMapKeyboardHandler(this); linkMapAndPhoto(this); } /** @private */ async _postAPIInit() { this.loader.setAttribute("value", 30); this._createInitParamsHandler(); const myPostInitParams = this._initParams.getParentPostInit(); this._initPSV(); await this._initMap(); this._initWidgets(); // Re-launch slot move (for those depending on widgets) this._moveChildToGrid(); alterViewerState(this, myPostInitParams); if(myPostInitParams.keyboardShortcuts) { this._handleKeyboardManagement(); } if(myPostInitParams.willLoadPicture) { this.psv.addEventListener("picture-loaded", e => { alterViewerState(this, myPostInitParams); // Do it again for forcing focus this.loader.dismiss(); }, {once: true}); } else { this.loader.dismiss(); } } /** @private */ _enableKeyboard() { if(this.map && this.isMapWide()) { this._enableKeyboardMap(); } else { this._enableKeyboardPSV(); } } /** @private */ _enableKeyboardMap() { this.psv.stopKeyboardControl(); this.map.keyboard.enable(); } /** @private */ _enableKeyboardPSV() { this.psv.startKeyboardControl(); this.map.keyboard.disable(); } /** @private */ _disableKeyboard() { this.psv.stopKeyboardControl(); this.map.keyboard.disable(); } /** @private */ _toggleKeyboardBasedOnFocus(e) { const target = e?.target || document.activeElement; if(this.contains(target)) { // Check if focus is not in a widget for(let cn of this.grid.childNodes) { if( cn.getAttribute("slot") !== "bg" && !KEYBOARD_SKIP_FOCUS_WIDGETS.includes(cn.tagName.toLowerCase()) ) { if(cn.contains(target)) { this._disableKeyboard(); return; } } } this._enableKeyboard(); } else { this._disableKeyboard(); } } /** @private */ _handleKeyboardManagement() { // General window.addEventListener("click", e => this._toggleKeyboardBasedOnFocus(e)); window.addEventListener("keypress", this._toggleKeyboardBasedOnFocus.bind(this)); this.addEventListener("focus-changed", e => { if(this.popup.getAttribute("visible")) { this._disableKeyboard(); } else if(e.detail.focus === "map") { this._enableKeyboardMap(); } else { this._enableKeyboardPSV(); } }); // Popup this.popup.addEventListener("open", this._disableKeyboard.bind(this)); this.popup.addEventListener("close", this._enableKeyboard.bind(this)); this.psv.addEventListener("click", this._enableKeyboardPSV.bind(this)); // Widgets for(let cn of this.grid.childNodes) { if( cn.getAttribute("slot") !== "bg" && !KEYBOARD_SKIP_FOCUS_WIDGETS.includes(cn.tagName.toLowerCase()) ) { cn.addEventListener("focusin", this._disableKeyboard.bind(this)); cn.addEventListener("focusout", () => { if(this.popup.getAttribute("visible") === null) { this._enableKeyboard(); } }); } } } /** * 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.Viewer# */ moveCenter() { const meta = this.psv.getPictureMetadata(); if(!meta) { return; } if(this.map && this.isMapWide()) { this.map.flyTo({ center: meta.gps, zoom: 20 }); } else { super.moveCenter(); } } /** * Moves map or picture viewer to given direction. * @param {string} dir Direction to move to (up, left, down, right) * @private */ _moveToDirection(dir) { if(this.map && this.isMapWide()) { let pan; switch(dir) { case "up": pan = [0, -MAP_MOVE_DELTA]; break; case "left": pan = [-MAP_MOVE_DELTA, 0]; break; case "down": pan = [0, MAP_MOVE_DELTA]; break; case "right": pan = [MAP_MOVE_DELTA, 0]; break; } this.map.panBy(pan); } else { super._moveToDirection(dir); } } /** * Is the map shown as main element instead of viewer (wide map mode) ? * @memberof Panoramax.components.core.Viewer# * @returns {boolean} True if map is wider than viewer */ isMapWide() { return this.mapContainer.parentNode == this.grid; } /** * Change the viewer focus (either on picture or map) * @memberof Panoramax.components.core.Viewer# * @param {string} focus The object to focus on (map, pic) * @param {boolean} [skipEvent=false] True to not send focus-changed event * @param {boolean} [skipDupCheck=false] True to avoid duplicate calls check * @private */ _setFocus(focus, skipEvent = false, skipDupCheck = false) { if(focus === "map" && !this.map) { throw new Error("Map is not enabled"); } if(!["map", "pic"].includes(focus)) { throw new Error("Invalid focus value (should be pic or map)"); } this.focus = focus; if(!skipDupCheck && ( (focus === "map" && this.map && this.isMapWide()) || (focus === "pic" && (!this.map || !this.isMapWide())) )) { return; } if(focus === "map") { // Remove PSV from grid if(this.psvContainer.parentNode == this.grid) { this.grid.removeChild(this.psvContainer); this.psvContainer.removeAttribute("slot"); } // Remove map from mini if(this.mapContainer.parentNode == this.mini) { this.mini.removeChild(this.mapContainer); } // Add map to grid this.mapContainer.setAttribute("slot", "bg"); this.grid.appendChild(this.mapContainer); // Add PSV to mini this.mini.appendChild(this.psvContainer); this.mini.icon = fa(faPanorama); if(this._miniPicLegend) { this.mini.appendChild(this._miniPicLegend); } // Hide mini icon if no picture selected if(isNullId(this.picture)) { this.mini.classList.add("pnx-hidden"); } else { this.mini.classList.remove("pnx-hidden"); } this.map.getCanvas().focus(); } else { // Remove map from grid if(this.mapContainer.parentNode == this.grid) { this.grid.removeChild(this.mapContainer); this.mapContainer.removeAttribute("slot"); } // Remove PSV from mini if(this.psvContainer.parentNode == this.mini) { this.mini.removeChild(this.psvContainer); if(this._miniPicLegend) { this.mini.removeChild(this._miniPicLegend); } } // Add PSV to grid this.psvContainer.setAttribute("slot", "bg"); this.grid.appendChild(this.psvContainer); // Add map to mini this.mini.classList.remove("pnx-hidden"); this.mini.appendChild(this.mapContainer); this.mini.icon = fa(faMap); this.psvContainer.focus(); } this?.map?.resize?.(); this.psv.autoSize(); this.psv.forceRefresh(); this.legend?.setAttribute?.("focus", this.focus); if(!skipEvent) { /** * Event for focus change (either map or picture is shown wide) * @event Panoramax.components.core.Viewer#focus-changed * @type {CustomEvent} * @property {string} detail.focus Component now focused on (map, pic) */ const event = new CustomEvent("focus-changed", { detail: { focus } }); this.dispatchEvent(event); } } /** * Toggle the viewer focus (either on picture or map) * @memberof Panoramax.components.core.Viewer# * @private */ _toggleFocus() { this._setFocus(this.isMapWide() ? "pic" : "map"); } /** @private */ _onMiniExpand() { this.map.resize(); this.psv.autoSize(); } /** * Send viewer new map filters values. * @private */ _onMapFiltersChange() { const mapFiltersMenu = querySelectorDeep("#pnx-map-filters-menu"); const fMapTheme = querySelectorDeep("#pnx-map-theme"); const values = mapFiltersFormValues(mapFiltersMenu, fMapTheme, this.map?._hasQualityScore()); this.map.setFilters(values); } } customElements.define("pnx-viewer", Viewer);