@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
629 lines (560 loc) • 20.9 kB
JavaScript
/* 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);