UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

1,109 lines (1,004 loc) 34.9 kB
import "./Photo.css"; import LoaderImgBase from "../../img/loader_base.jpg"; import LogoDead from "../../img/logo_dead.svg"; import { getDistance, positionToXYZ, xyzToPosition, getRelativeHeading, BASE_PANORAMA_ID, isNullId, } from "../../utils/utils"; import { apiFeatureToPSVNode } from "../../utils/picture"; // Photo Sphere Viewer imports import "@photo-sphere-viewer/core/index.css"; import "@photo-sphere-viewer/virtual-tour-plugin/index.css"; import "@photo-sphere-viewer/gallery-plugin/index.css"; import "@photo-sphere-viewer/markers-plugin/index.css"; import { Viewer as PSViewer } from "@photo-sphere-viewer/core"; import { VirtualTourPlugin } from "@photo-sphere-viewer/virtual-tour-plugin"; import { MarkersPlugin } from "@photo-sphere-viewer/markers-plugin"; import PhotoAdapter from "../../utils/PhotoAdapter"; // Default panorama (logo) const BASE_PANORAMA = { baseUrl: LoaderImgBase, width: 1280, cols: 2, rows: 1, tileUrl: () => null, }; const BASE_PANORAMA_NODE = { id: BASE_PANORAMA_ID, caption: "", panorama: BASE_PANORAMA, links: [], gps: [0,0], sequence: {}, sphereCorrection: {}, horizontalFov: 360, properties: {}, }; export const PSV_DEFAULT_ZOOM = 30; // eslint-disable-line import/no-unused-modules export const PSV_ANIM_DURATION = 250; export const PIC_MAX_STAY_DURATION = 3000; PSViewer.useNewAnglesOrder = true; /** * Triggered once when the panorama image has been loaded and the viewer is ready to perform the first render. * @see {@link https://photo-sphere-viewer.js.org/guide/events.html#ready|Photo Sphere Viewer documentation} * @event Panoramax.components.ui.Photo#ready * @memberof Panoramax.components.ui.Photo * @type {Event} */ /** * Photo is the component showing a single picture. * It uses Photo Sphere Viewer as a basis, and pre-configure dialog with STAC API. * * Note that all functions of [PhotoSphereViewer Viewer class](https://photo-sphere-viewer.js.org/api/classes/core.viewer) are available as well. * * @class Panoramax.components.ui.Photo * @extends [photo-sphere-viewer.core.Viewer](https://photo-sphere-viewer.js.org/api/classes/Core.Viewer.html) * @param {Panoramax.components.core.basic} parent The parent view * @param {Element} container The DOM element to create into * @param {object} [options] The viewer options. Can be any of [Photo Sphere Viewer options](https://photo-sphere-viewer.js.org/guide/config.html#standard-options) * @param {number} [options.transitionDuration] The number of milliseconds the transition animation should be. * @param {number[]} [options.position] Initial geographical coordinates (as [latitude, longitude]) to find picture nearby. Only used if no picture ID is set. * @param {function} [options.shouldGoFast] Function returning a boolean to indicate if we may skip loading HD images. * @param {string} [options.picturesNavigation=any] The allowed pictures navigation ("any": no restriction, "seq": only pictures in same sequence, "pic": only selected picture) * @fires Panoramax.components.ui.Photo#picture-loading * @fires Panoramax.components.ui.Photo#picture-preview-started * @fires Panoramax.components.ui.Photo#picture-preview-stopped * @fires Panoramax.components.ui.Photo#view-rotated * @fires Panoramax.components.ui.Photo#picture-loaded * @fires Panoramax.components.ui.Photo#picture-tiles-loaded * @fires Panoramax.components.ui.Photo#transition-duration-changed * @fires Panoramax.components.ui.Photo#sequence-playing * @fires Panoramax.components.ui.Photo#sequence-stopped * @fires Panoramax.components.ui.Photo#pictures-navigation-changed * @fires Panoramax.components.ui.Photo#ready * @fires Panoramax.components.ui.Photo#annotations-toggled * @fires Panoramax.components.ui.Photo#annotation-click * @fires Panoramax.components.ui.Photo#annotations-unfocused * @example * const psv = new Panoramax.components.ui.Photo(viewer, psvNode, {transitionDuration: 500}) */ export default class Photo extends PSViewer { constructor(parent, container, options = {}) { super({ container, adapter: [PhotoAdapter, { showErrorTile: false, baseBlur: false, resolution: parent.isWidthSmall() ? 32 : 64, shouldGoFast: options.shouldGoFast, }], withCredentials: parent.api._getPSVWithCredentials(), requestHeaders: parent?.fetchOptions?.headers, panorama: BASE_PANORAMA, lang: parent._t.psv, minFov: 5, loadingTxt: "&nbsp;", navbar: null, rendererParameters: { preserveDrawingBuffer: !parent.isWidthSmall(), }, plugins: [ [VirtualTourPlugin, { dataMode: "server", positionMode: "gps", renderMode: "3d", preload: true, getNode: () => {}, transitionOptions: () => {}, arrowsPosition: { linkOverlapAngle: Math.PI / 6, } }], [MarkersPlugin, {}], ], ...options }); this._parent = parent; this._options = options; container.classList.add("pnx-psv"); this._shouldGoFast = options?.shouldGoFast || (() => false); this._transitionDuration = options?.transitionDuration || PSV_ANIM_DURATION; this._myVTour = this.getPlugin(VirtualTourPlugin); this._myVTour.datasource.nodeResolver = this._getNodeFromAPI.bind(this); this._myVTour.config.transitionOptions = this._psvNodeTransition.bind(this); this._clearArrows = this._myVTour.arrowsRenderer.clear.bind(this._myVTour.arrowsRenderer); this._myVTour.arrowsRenderer.clear = () => {}; this._myMarkers = this.getPlugin(MarkersPlugin); this._annotationsVisible = false; this._sequencePlaying = false; this._picturesNavigation = this._options.picturesNavigation || "any"; // Cache to find sequence ID for a single picture this._picturesSequences = {}; // Offer various custom events this._myVTour.addEventListener("enter-arrow", this._onEnterArrow.bind(this)); this._myVTour.addEventListener("leave-arrow", this._onLeaveArrow.bind(this)); this._myVTour.addEventListener("node-changed", this._onNodeChanged.bind(this)); this._myMarkers.addEventListener("select-marker", this._onSelectMarker.bind(this)); this.addEventListener("position-updated", this._onPositionUpdated.bind(this)); this.addEventListener("zoom-updated", this._onZoomUpdated.bind(this)); this.addEventListener("dblclick", this._onDoubleClick.bind(this)); this._parent.addEventListener("select", this._onSelect.bind(this)); // Fix for loader circle background not showing up this.loader.size = 150; this.loader.color = "rgba(61, 61, 61, 0.5)"; this.loader.textColor = "rgba(255, 255, 255, 0.7)"; this.loader.border = 5; this.loader.thickness = 10; this.loader.canvas.setAttribute("viewBox", "0 0 150 150"); this.loader.__updateContent(); // Handle initial parameters if(this._options.position && !this._parent.picture) { this.goToPosition(...this._options.position); } } /** * Calls API to retrieve a certain picture, then transforms into PSV format * * @private * @param {string} picId The picture UUID * @returns {Promise} Resolves on PSV node metadata * @memberof Panoramax.components.ui.Photo# */ async _getNodeFromAPI(picId) { if(isNullId(picId)) { return BASE_PANORAMA_NODE; } const picApiResponse = await fetch( this._parent.api.getPictureMetadataUrl(picId, this._picturesSequences[picId]), this._parent.api._getFetchOptions() ); let metadata = await picApiResponse.json(); if(metadata.features) { metadata = metadata.features.pop(); } if(!metadata || Object.keys(metadata).length === 0 || !picApiResponse.ok) { if(this._parent.loader) { this._parent.loader.dismiss(true, this._parent._t.pnx.error_pic); } throw new Error("Picture with ID " + picId + " was not found"); } this._picturesSequences[picId] = metadata.collection; const node = apiFeatureToPSVNode( metadata, this._parent._t, this._parent._isInternetFast, this._picturesNavFilter.bind(this) ); if(node?.sequence?.prevPic) { this._picturesSequences[node?.sequence?.prevPic] = metadata.collection; } if(node?.sequence?.nextPic) { this._picturesSequences[node?.sequence?.nextPic] = metadata.collection; } return node; } /** * PSV node transition handler * @param {*} toNode Next loading node * @param {*} [fromNode] Currently shown node (previous) * @param {*} [fromLink] Link clicked by user to go from current to next node * @private * @memberof Panoramax.components.ui.Photo# */ _psvNodeTransition(toNode, fromNode, fromLink) { let nodeTransition = {}; const animationDuration = this._shouldGoFast() ? 0 : Math.min(PSV_ANIM_DURATION, this._transitionDuration); const animated = animationDuration > 100; const following = (fromLink || fromNode?.links.find(a => a.nodeId == toNode.id)) != null; const sameSequence = fromNode && toNode.sequence.id === fromNode.sequence.id; const fromNodeHeading = (fromNode?.properties?.["view:azimuth"] || 0) * (Math.PI / 180); const toNodeHeading = (toNode?.properties?.["view:azimuth"] || 0) * (Math.PI / 180); const toNodeRelHeading = getRelativeHeading(toNode) * (Math.PI / 180); this.setOption("maxFov", Math.min(toNode.horizontalFov * 3/4, 90)); const forwardNoAnim = { showLoader: false, effect: "none", speed: 0, rotation: false, rotateTo: { pitch: 0, yaw: -toNodeRelHeading }, zoomTo: PSV_DEFAULT_ZOOM }; // Going to 360 if(toNode.horizontalFov == 360) { // No previous sequence -> Point to center + no animation if(!fromNode) { nodeTransition = forwardNoAnim; } // Has a previous sequence else { // Far away sequences -> Point to center + no animation if(getDistance(fromNode.gps, toNode.gps) >= 0.001) { nodeTransition = forwardNoAnim; } // Nearby sequences -> Keep orientation else { nodeTransition = { speed: animationDuration, effect: following && animated ? "fade" : "none", rotation: following && sameSequence && animated, rotateTo: this.getPosition() }; // Constant direction related to North // nodeTransition.rotateTo.yaw += fromNodeHeading - toNodeHeading; } } } // Going to flat else { // Same sequence -> Point to center + animation if following pics + not vomiting if(sameSequence) { const fromYaw = this.getPosition().yaw; const fovMaxYaw = (fromNode.horizontalFov * (Math.PI / 180)) / 2; const keepZoomPos = fromYaw <= fovMaxYaw || fromYaw >= (2 * Math.PI - fovMaxYaw); const notTooMuchRotation = Math.abs(fromNodeHeading - toNodeHeading) <= Math.PI / 4; nodeTransition = { speed: animationDuration, effect: following && notTooMuchRotation && animated ? "fade" : "none", rotation: following && notTooMuchRotation && animated, rotateTo: keepZoomPos ? this.getPosition() : { pitch: 0, yaw: 0 }, zoomTo: keepZoomPos ? this.getZoomLevel() : PSV_DEFAULT_ZOOM, }; } // Different sequence -> Point to center + no animation else { nodeTransition = Object.assign(forwardNoAnim, { rotateTo: { pitch: 0, yaw: 0 }, }); } } if(nodeTransition.effect === "fade" && nodeTransition.speed >= 150) { setTimeout(this._clearArrows, nodeTransition.speed-100); } else { this._clearArrows(); } /** * Event for picture starting to load * * @event Panoramax.components.ui.Photo#picture-loading * @type {CustomEvent} * @property {string} detail.picId The picture unique identifier * @property {number} detail.lon Longitude (WGS84) * @property {number} detail.lat Latitude (WGS84) * @property {number} detail.x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West) * @property {number} detail.y New y position (in degrees) * @property {number} detail.z New z position (0-100) * @property {boolean} detail.first True if first picture loaded */ const event = new CustomEvent("picture-loading", { detail: { ...Object.assign({}, this.getXYZ(), nodeTransition.rotateTo ? { x: (toNodeHeading + nodeTransition.rotateTo.yaw) * 180 / Math.PI } : null, nodeTransition.zoomTo ? { z: nodeTransition.zoomTo } : null ), picId: toNode.id, lon: toNode.gps[0], lat: toNode.gps[1], first: this._parent._initParams?.getParentPostInit().picture == toNode.id, } }); this.dispatchEvent(event); return nodeTransition; } /** * Event handler for PSV arrow hover. * It creates a custom event "picture-preview-started" * @private * @param {object} e The event data * @memberof Panoramax.components.ui.Photo# */ _onEnterArrow(e) { const fromLink = e.link; const fromNode = e.node; // Find probable direction for previewed picture let direction; if(fromNode) { if(fromNode.horizontalFov === 360) { direction = (this.getPictureOriginalHeading() + this.getPosition().yaw * 180 / Math.PI) % 360; } else { direction = this.getPictureOriginalHeading(); } } /** * Event for picture preview * * @event Panoramax.components.ui.Photo#picture-preview-started * @type {CustomEvent} * @property {string} detail.picId The picture ID * @property {number[]} detail.coordinates [x,y] coordinates * @property {number} detail.direction The theoretical picture orientation */ const event = new CustomEvent("picture-preview-started", { detail: { picId: fromLink.nodeId, coordinates: fromLink.gps, direction, }}); this.dispatchEvent(event); } /** * Event handler for PSV arrow end of hovering. * It creates a custom event "picture-preview-stopped" * @private * @param {object} e The event data * @memberof Panoramax.components.ui.Photo# */ _onLeaveArrow(e) { const fromLink = e.link; /** * Event for end of picture preview * @event Panoramax.components.ui.Photo#picture-preview-stopped * @type {CustomEvent} * @property {string} detail.picId The picture ID */ const event = new CustomEvent("picture-preview-stopped", { detail: { picId: fromLink.nodeId, }}); this.dispatchEvent(event); } /** * Event handler for position update in PSV. * Allows to send a custom "view-rotated" event. * @private * @memberof Panoramax.components.ui.Photo# */ _onPositionUpdated({position}) { const pos = positionToXYZ(position, this.getZoomLevel()); pos.x += this.getPictureOriginalHeading(); pos.x = pos.x % 360; /** * Event for viewer rotation * @event Panoramax.components.ui.Photo#view-rotated * @type {CustomEvent} * @property {number} detail.x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West) * @property {number} detail.y New y position (in degrees) * @property {number} detail.z New Z position (between 0 and 100) */ const event = new CustomEvent("view-rotated", { detail: pos }); this.dispatchEvent(event); this._onTilesStartLoading(); } /** * Event handler for zoom updates in PSV. * Allows to send a custom "view-rotated" event. * @private * @memberof Panoramax.components.ui.Photo# */ _onZoomUpdated({zoomLevel}) { const event = new CustomEvent("view-rotated", { detail: { ...this.getXY(), z: zoomLevel} }); this.dispatchEvent(event); this._onTilesStartLoading(); } /** * Event handler for double click * @private */ _onDoubleClick() { this.unfocusAnnotation(); } /** * Event handler for node change in PSV. * Allows to send a custom "picture-loaded" event. * @private * @memberof Panoramax.components.ui.Photo# */ _onNodeChanged(e) { // Clean up clicked arrows for(let d of document.getElementsByClassName("pnx-psv-tour-arrows")) { d.classList.remove("pnx-clicked"); } if(e.node.id) { const isFirst = this._parent._initParams?.getParentPostInit().picture == e.node.id; this._parent.select(e.node?.sequence?.id, e.node.id); const picMeta = this.getPictureMetadata(); if(!picMeta) { this.dispatchEvent(new CustomEvent("picture-loaded", {detail: {}})); return; } this._prevSequence = picMeta.sequence.id; /** * Event for picture load (low-resolution image is loaded) * @event Panoramax.components.ui.Photo#picture-loaded * @type {CustomEvent} * @property {string} detail.picId The picture unique identifier * @property {number} detail.lon Longitude (WGS84) * @property {number} detail.lat Latitude (WGS84) * @property {number} detail.x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West) * @property {number} detail.y New y position (in degrees) * @property {number} detail.z New z position (0-100) * @property {boolean} detail.first True if first picture loaded */ const event = new CustomEvent("picture-loaded", { detail: { ...this.getXYZ(), picId: e.node.id, lon: picMeta.gps[0], lat: picMeta.gps[1], first: isFirst }, }); this.dispatchEvent(event); // Change download URL if(picMeta.panorama.hdUrl) { this.setOption("downloadUrl", picMeta.panorama.hdUrl); this.setOption("downloadName", e.node.id+".jpg"); } else { this.setOption("downloadUrl", null); } // Show annotations if(this._annotationsVisible) { this.toggleAllAnnotations(true); } } this._onTilesStartLoading(); } /** * Event handler for marker select * @memberof Panoramax.components.ui.Photo# * @private */ _onSelectMarker(e) { if(!e.marker) { return; } if(e.marker.id?.startsWith("annotation-")) { /** * Event launched on annotation click over picture * @event Panoramax.components.ui.Photo#annotation-click * @type {CustomEvent} * @property {string} detail.annotationId The annotation UUID */ const event = new CustomEvent("annotation-click", { detail: { annotationId: e.marker.data.id }}); this.dispatchEvent(event); } } /** * Event handler for loading a new range of tiles * @memberof Panoramax.components.ui.Photo# * @private */ _onTilesStartLoading() { if(this._tilesQueueTimer) { clearInterval(this._tilesQueueTimer); delete this._tilesQueueTimer; } this._tilesQueueTimer = setInterval(() => { if(Object.keys(this.adapter.queue.tasks).length === 0) { if(this._myVTour.state.currentNode) { /** * Event launched when all visible tiles of a picture are loaded * @event Panoramax.components.ui.Photo#picture-tiles-loaded * @type {CustomEvent} * @property {string} detail.picId The picture unique identifier */ const event = new CustomEvent("picture-tiles-loaded", { detail: { picId: this._myVTour.state.currentNode.id }}); this.dispatchEvent(event); } clearInterval(this._tilesQueueTimer); delete this._tilesQueueTimer; } }, 100); } /** * Access currently shown picture metadata * @memberof Panoramax.components.ui.Photo# * @returns {object} Picture metadata */ getPictureMetadata() { if(isNullId(this._myVTour?.state?.currentNode?.id)) { return null; } return this._myVTour.state.currentNode ? Object.assign({}, this._myVTour.state.currentNode) : null; } /** * Get current picture ID, or loading picture ID if any. * @memberof Panoramax.components.ui.Photo# * @returns {string|null} Picture ID (current or loading), or null if none is selected. */ getPictureId() { const id = this._myVTour?.state?.loadingNode || this._myVTour?.state?.currentNode?.id; return isNullId(id) ? null : id; } /** * Handler for select event. * @private * @memberof Panoramax.components.ui.Photo# */ _onSelect(e) { if(e.detail.seqId) { this._picturesSequences[e.detail.picId] = e.detail.seqId; } if(this._myVTour.getCurrentNode()?.id !== e.detail.picId) { this.loader.show(); this._myVTour.setCurrentNode(e.detail.picId).catch(e => { this.showErrorOverlay(e, this._parent._t.pnx.error_pic, true); }); } } /** * Displays next picture in current sequence (if any) * @memberof Panoramax.components.ui.Photo# * @throws {Error} If no picture is selected, or no next picture available */ goToNextPicture() { if(!this.getPictureMetadata()) { throw new Error("No picture currently selected"); } const next = this.getPictureMetadata().sequence.nextPic; if(next) { this._parent.select(this.getPictureMetadata().sequence.id, next); } else { throw new Error("No next picture available"); } } /** * Displays previous picture in current sequence (if any) * @memberof Panoramax.components.ui.Photo# * @throws {Error} If no picture is selected, or no previous picture available */ goToPrevPicture() { if(!this.getPictureMetadata()) { throw new Error("No picture currently selected"); } const prev = this.getPictureMetadata().sequence.prevPic; if(prev) { this._parent.select(this.getPictureMetadata().sequence.id, prev); } else { throw new Error("No previous picture available"); } } /** * Displays in viewer a picture near to given coordinates * @memberof Panoramax.components.ui.Photo# * @param {number} lat Latitude (WGS84) * @param {number} lon Longitude (WGS84) * @returns {Promise} * @fulfil {string} Picture ID if picture found * @reject {Error} If no picture found */ async goToPosition(lat, lon) { return this._parent.api.getPicturesAroundCoordinates(lat, lon) .then(res => { if(res.features.length > 0) { const f = res.features.pop(); this._parent.select( f?.collection, f.id ); return f.id; } else { return Promise.reject(new Error("No picture found nearby given coordinates")); } }); } /** * Get 2D position of sphere currently shown to user * @memberof Panoramax.components.ui.Photo# * @returns {object} Position in format { x: heading in degrees (0° = North, 90° = East, 180° = South, 270° = West), y: top/bottom position in degrees (-90° = bottom, 0° = front, 90° = top) } */ getXY() { const pos = positionToXYZ(this.getPosition()); pos.x = (pos.x + this.getPictureOriginalHeading()) % 360; return pos; } /** * Get 3D position of sphere currently shown to user * @memberof Panoramax.components.ui.Photo# * @returns {object} Position in format { x: heading in degrees (0° = North, 90° = East, 180° = South, 270° = West), y: top/bottom position in degrees (-90° = bottom, 0° = front, 90° = top), z: zoom (0 = wide, 100 = zoomed in) } */ getXYZ() { const pos = this.getXY(); pos.z = this.getZoomLevel(); return pos; } /** * Get capture orientation of current picture, based on its GPS. * @returns {number} Picture original heading in degrees (0 to 360°) * @memberof Panoramax.components.ui.Photo# */ getPictureOriginalHeading() { return this.getPictureMetadata()?.properties?.["view:azimuth"] || 0; } /** * Computes the relative heading of currently selected picture. * This gives the angle of capture compared to sequence path (vehicle movement). * @memberof Panoramax.components.ui.Photo# * @returns {number} Relative heading in degrees (-180 to 180) */ getPictureRelativeHeading() { return getRelativeHeading(this.getPictureMetadata()); } /** * Clears the Photo Sphere Viewer metadata cache. * It is useful when current picture or sequence has changed server-side after first load. * @memberof Panoramax.components.ui.Photo# */ clearPictureMetadataCache() { const oldPicId = this.getPictureMetadata()?.id; const oldSeqId = this.getPictureMetadata()?.sequence?.id; // Force deletion of cached metadata in PSV this._myVTour.state.currentTooltip?.hide(); this._myVTour.state.currentTooltip = null; this._myVTour.state.currentNode = null; this._myVTour.state.preload = {}; this._myVTour.datasource.nodes = {}; // Reload current picture if one was selected if(oldPicId) { this._parent.select(oldSeqId, oldPicId); } } /** * Change the shown position in picture * @memberof Panoramax.components.ui.Photo# * @param {number} x X position (in degrees) * @param {number} y Y position (in degrees) * @param {number} z Z position (0-100) */ setXYZ(x, y, z) { const coords = xyzToPosition(x - this.getPictureOriginalHeading(), y, z); this.rotate({ yaw: coords.yaw, pitch: coords.pitch }); this.zoom(coords.zoom); } /** * Enable or disable higher contrast on picture * @param {boolean} enable True to enable higher contrast * @memberof Panoramax.components.ui.Photo# */ setHigherContrast(enable) { this.renderer.renderer.toneMapping = enable ? 3 : 0; this.renderer.renderer.toneMappingExposure = enable ? 2 : 1; this.needsUpdate(); } /** * Get the duration of stay on a picture during a sequence play. * @returns {number} The duration (in milliseconds) * @memberof Panoramax.components.ui.Photo# */ getTransitionDuration() { return this._transitionDuration; } /** * Changes the duration of stay on a picture during a sequence play. * @memberof Panoramax.components.ui.Photo# * @param {number} value The new duration (in milliseconds, between 100 and 3000) */ setTransitionDuration(value) { value = parseFloat(value); if(value < 100 || value > PIC_MAX_STAY_DURATION) { throw new Error("Invalid transition duration (should be between 100 and "+PIC_MAX_STAY_DURATION+")"); } this._transitionDuration = value; /** * Event for transition duration change * @event Panoramax.components.ui.Photo#transition-duration-changed * @type {CustomEvent} * @property {string} detail.duration New duration (in milliseconds) */ const event = new CustomEvent("transition-duration-changed", { detail: { value } }); this.dispatchEvent(event); } /** @private */ setPanorama(path, options) { const onFailure = e => this.showErrorOverlay(e, this._parent?._t.pnx.error_pic, true); try { return super.setPanorama(path, options).catch(onFailure); } catch(e) { onFailure(e); } } /** * Display an error message to user on screen * @param {object} e The initial error * @param {str} label The main error label to display * @param {boolean} dissmisable Is error dissmisable * @memberof Panoramax.components.ui.Photo# */ showErrorOverlay(e, label, dissmisable) { if(this._parent?.loader.isVisible() || !this.overlay.isVisible()) { this._parent?.loader.dismiss( e, label, dissmisable ? () => { this._parent?.loader.dismiss(); this.overlay.hide(); } : undefined ); } else { console.error(e); this.overlay.show({ image: `<img style="width: 200px" src="${LogoDead}" alt="" />`, title: this._parent?._t.pnx.error, text: label + "<br />" + this._parent?._t.pnx.error_click, dissmisable, }); } } /** * Goes continuously to next picture in sequence as long as possible * @memberof Panoramax.components.ui.Photo# */ playSequence() { this._sequencePlaying = true; this.container.classList.add("pnx-psv-playing"); /** * Event for sequence starting to play * @event Panoramax.components.ui.Photo#sequence-playing * @type {CustomEvent} */ const event = new Event("sequence-playing", {bubbles: true, composed: true}); this.dispatchEvent(event); const nextPicturePlay = () => { if(this._sequencePlaying) { this.addEventListener("picture-loaded", () => { this._playTimer = setTimeout(() => { nextPicturePlay(); }, this.getTransitionDuration()); }, { once: true }); try { this.goToNextPicture(); } catch(e) { this.stopSequence(); } } }; // Stop playing if user clicks on image this.addEventListener("click", () => this.stopSequence()); nextPicturePlay(); } /** * Stops playing current sequence * @memberof Panoramax.components.ui.Photo# */ stopSequence() { this._sequencePlaying = false; this.container.classList.remove("pnx-psv-playing"); // Next picture timer is pending if(this._playTimer) { clearTimeout(this._playTimer); delete this._playTimer; } // Force refresh of PSV to eventually load tiles this.forceRefresh(); /** * Event for sequence stopped playing * @event Panoramax.components.ui.Photo#sequence-stopped * @type {CustomEvent} */ const event = new Event("sequence-stopped", {bubbles: true, composed: true}); this.dispatchEvent(event); } /** * Is there any sequence being played right now ? * @memberof Panoramax.components.ui.Photo# * @returns {boolean} True if sequence is playing */ isSequencePlaying() { return this._sequencePlaying; } /** * Starts/stops the reading of pictures in a sequence * @memberof Panoramax.components.ui.Photo# */ toggleSequencePlaying() { if(this.isSequencePlaying()) { this.stopSequence(); } else { this.playSequence(); } } /** * Get current pictures navigation mode. * @returns {string} The picture navigation mode ("any": no restriction, "seq": only pictures in same sequence, "pic": only selected picture) * @memberof Panoramax.components.ui.Photo# */ getPicturesNavigation() { return this._picturesNavigation; } /** * Switch the allowed navigation between pictures. * @param {string} pn The picture navigation mode ("any": no restriction, "seq": only pictures in same sequence, "pic": only selected picture) * @memberof Panoramax.components.ui.Photo# */ setPicturesNavigation(pn) { if(pn === "none") { pn = "pic"; } this._picturesNavigation = pn; /** * Event for pictures navigation mode change * @event Panoramax.components.ui.Photo#pictures-navigation-changed * @type {CustomEvent} * @property {string} detail.value New mode (any, pic, seq) */ const event = new CustomEvent("pictures-navigation-changed", { detail: { value: pn } }); this.dispatchEvent(event); } /** * Filter function * @param {object} link A STAC next/prev/related link definition * @returns {boolean} True if link should be kept * @private */ _picturesNavFilter(link) { switch(this._picturesNavigation) { case "seq": return ["next", "prev"].includes(link.rel); case "pic": case "none": return false; case "any": default: return true; } } /** * Are there any picture annotations shown ? * @returns {boolean} True if annotations are visible * @memberof Panoramax.components.ui.Photo# */ areAnnotationsVisible() { return this._annotationsVisible; } /** * Toggle visibility of picture annotations * @param {boolean} visible True to make visible, false to hide * @memberof Panoramax.components.ui.Photo# */ toggleAllAnnotations(visible) { const meta = this.getPictureMetadata(); if(!meta) { this._myMarkers.clearMarkers(); throw new Error("No picture currently selected"); } if(!visible) { this._myMarkers.clearMarkers(); } else { let annotations = meta.properties.annotations || []; if(annotations?.length === 0) { console.warn("No annotation available on picture", meta.id); } const picBData = this.state.textureData.panoData?.baseData; annotations = annotations.map(a => { // Get original HD picture dimensions const origPicDim = this.getPictureMetadata().properties["pers:interior_orientation"].sensor_array_dimensions; if(!origPicDim) { console.warn("Picture lacks pers:interior_orientation.sensor_array_dimensions property, can't compute marker"); return null; } const shape = a.shape.coordinates.map(c1 => c1.map(c2 => { // Px coordinates in shown image const pxShown = [ c2[0] * picBData.croppedWidth / origPicDim[0], c2[1] * picBData.croppedHeight / origPicDim[1] ]; // Coords in % const pct = [ (picBData.croppedX + pxShown[0]) / picBData.fullWidth, (picBData.croppedY + pxShown[1]) / picBData.fullHeight ]; // Coords in radians as center offset return [ (pct[0] - 0.5) * 2 * Math.PI, (0.5 - pct[1]) * Math.PI, ]; })); return { id: `annotation-${a.id}`, polygon: shape, data: { id: a.id }, className: "pnx-psv-annotation", svgStyle: { stroke: "var(--orange)", strokeWidth: "3px", fill: "var(--orange-transparent)", cursor: "pointer", }, tooltip: this._parent._t.pnx.semantics_annotation_tooltip, }; }); this._myMarkers.setMarkers(annotations); } const sendEvent = this._annotationsVisible != visible; this._annotationsVisible = visible; if(sendEvent) { /** * Event for pictures annotation visibility change * @event Panoramax.components.ui.Photo#annotations-toggled * @type {CustomEvent} * @property {boolean} detail.visible True if they are visible */ this.dispatchEvent(new CustomEvent("annotations-toggled", { detail: { visible } })); } } /** * Make view centered and zoomed on given annotation. * @param {string} id The annotation UUID * @memberof Panoramax.components.ui.Photo# */ focusOnAnnotation(id) { if(!this.areAnnotationsVisible()) { this.toggleAllAnnotations(true); } this.unfocusAnnotation(true); const annotationId = `annotation-${id}`; this._myMarkers.updateMarker({ id: annotationId, svgStyle: { stroke: "var(--red)", strokeWidth: "3px", fill: "var(--red-transparent)", }, data: { selected: true, } }); this._myMarkers.gotoMarker(annotationId, 0); this.zoom(65); } /** * Remove focus styling on annotations. * @memberof Panoramax.components.ui.Photo# * @param {boolean} [skipEvent=false] Set to true to avoid launching annotations-unfocused event */ unfocusAnnotation(skipEvent = false) { const selectedAnnotations = Object.keys(this._myMarkers.markers) .filter(id => id.startsWith("annotation-") && this._myMarkers.markers[id]?.config?.data?.selected); if(selectedAnnotations.length > 0) { selectedAnnotations.forEach(id => { this._myMarkers.updateMarker({ id, svgStyle: { stroke: "var(--orange)", strokeWidth: "3px", fill: "var(--orange-transparent)", }, data: { selected: false, } }); }); if(!skipEvent) { /** * Event for pictures annotation unfocus * @event Panoramax.components.ui.Photo#annotations-unfocused * @type {Event} */ this.dispatchEvent(new Event("annotations-unfocused")); } } } /** * Force reload of texture and tiles. * @memberof Panoramax.components.ui.Photo# */ forceRefresh() { const cn = this._myVTour.getCurrentNode(); // Refresh mode for flat pictures if(cn && cn.panorama.baseUrl !== cn?.panorama?.origBaseUrl) { const prevZoom = this.getZoomLevel(); const prevPos = this.getPosition(); this._myVTour.state.currentNode = null; this._myVTour.setCurrentNode(cn.id, { zoomTo: prevZoom, rotateTo: prevPos, fadeIn: false, speed: 0, rotation: false, }); } // Refresh mode for 360 pictures if(cn && cn.panorama.rows > 1) { this.adapter.__refresh(); } } }