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