@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
1,109 lines (1,004 loc) • 34.9 kB
JavaScript
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: " ",
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();
}
}
}