photo-sphere-viewer
Version:
A JavaScript library to display Photo Sphere panoramas
721 lines (617 loc) • 21 kB
JavaScript
import {
AmbientLight,
BackSide,
Group,
MathUtils,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
PointLight
} from 'three';
import { AbstractPlugin, CONSTANTS, PSVError, utils } from '../..';
import { ClientSideDatasource } from './ClientSideDatasource';
import {
ARROW_GEOM,
ARROW_OUTLINE_GEOM,
DEFAULT_ARROW,
DEFAULT_MARKER,
EVENTS,
LINK_DATA,
MODE_3D,
MODE_CLIENT,
MODE_GPS,
MODE_MANUAL,
MODE_MARKERS,
MODE_SERVER
} from './constants';
import { ServerSideDatasource } from './ServerSideDatasource';
import './style.scss';
import { bearing, distance, setMeshColor } from './utils';
/**
* @callback GetNode
* @summary Function to load a node
* @memberOf PSV.plugins.VirtualTourPlugin
* @param {string} nodeId
* @returns {PSV.plugins.VirtualTourPlugin.Node|Promise<PSV.plugins.VirtualTourPlugin.Node>}
*/
/**
* @callback Preload
* @summary Function to determine if a link must be preloaded
* @memberOf PSV.plugins.VirtualTourPlugin
* @param {PSV.plugins.VirtualTourPlugin.Node} node
* @param {PSV.plugins.VirtualTourPlugin.NodeLink} link
* @returns {boolean}
*/
/**
* @typedef {Object} PSV.plugins.VirtualTourPlugin.Node
* @summary Definition of a single node in the tour
* @property {string} id - unique identifier of the node
* @property {*} panorama
* @property {PSV.plugins.VirtualTourPlugin.NodeLink[]} [links] - links to other nodes
* @property {number[]} [position] - GPS position (longitude, latitude, optional altitude)
* @property {PSV.PanoData | PSV.PanoDataProvider} [panoData] - data used for this panorama
* @property {PSV.SphereCorrection} [sphereCorrection] - sphere correction to apply to this panorama
* @property {string} [name] - short name of the node
* @property {string} [caption] - caption visible in the navbar
* @property {string} [description] - description visible in the side panel
* @property {string} [thumbnail] - thumbnail for the gallery
* @property {PSV.plugins.MarkersPlugin.Properties[]} [markers] - additional markers to use on this node
*/
/**
* @typedef {PSV.ExtendedPosition} PSV.plugins.VirtualTourPlugin.NodeLink
* @summary Definition of a link between two nodes
* @property {string} nodeId - identifier of the target node
* @property {string} [name] - override the name of the node (tooltip)
* @property {number[]} [position] - override the GPS position of the node
* @property {PSV.plugins.MarkersPlugin.Properties} [markerStyle] - override global marker style
* @property {PSV.plugins.VirtualTourPlugin.ArrowStyle} [arrowStyle] - override global arrow style
*/
/**
* @typedef {Object} PSV.plugins.VirtualTourPlugin.ArrowStyle
* @summary Style of the arrow in 3D mode
* @property {string} [color=0xaaaaaa]
* @property {string} [hoverColor=0xaa5500]
* @property {number} [outlineColor=0x000000]
* @property {number[]} [scale=[0.5,2]]
*/
/**
* @typedef {Object} PSV.plugins.VirtualTourPlugin.Options
* @property {'client'|'server'} [dataMode='client'] - configure data mode
* @property {'manual'|'gps'} [positionMode='manual'] - configure positioning mode
* @property {'markers'|'3d'} [renderMode='3d'] - configure rendering mode of links
* @property {PSV.plugins.VirtualTourPlugin.Node[]} [nodes] - initial nodes
* @property {PSV.plugins.VirtualTourPlugin.GetNode} [getNode]
* @property {string} [startNodeId] - id of the initial node, if not defined the first node will be used
* @property {boolean|PSV.plugins.VirtualTourPlugin.Preload} [preload=false] - preload linked panoramas
* @property {boolean|string|number} [rotateSpeed='20rpm'] - speed of rotation when clicking on a link, if 'false' the viewer won't rotate at all
* @property {boolean|number} [transition=1500] - duration of the transition between nodes
* @property {boolean} [linksOnCompass] - if the Compass plugin is enabled, displays the links on the compass, defaults to `true` on in markers render mode
* @property {PSV.plugins.MarkersPlugin.Properties} [markerStyle] - global marker style
* @property {PSV.plugins.VirtualTourPlugin.ArrowStyle} [arrowStyle] - global arrow style
* @property {number} [markerLatOffset=-0.1] - (GPS & Markers mode) latitude offset applied to link markers, to compensate for viewer height
* @property {'top'|'bottom'} [arrowPosition='bottom'] - (3D mode) arrows vertical position
*/
/**
* @typedef {Object} PSV.plugins.VirtualTourPlugin.NodeChangedData
* @summary Data associated to the "node-changed" event
* @type {PSV.plugins.VirtualTourPlugin.Node} [fromNode] - The previous node
* @type {PSV.plugins.VirtualTourPlugin.NodeLink} [fromLink] - The link that was clicked in the previous node
* @type {PSV.Position} [fromLinkPosition] - The position of the link on the previous node
*/
export { EVENTS, MODE_3D, MODE_CLIENT, MODE_GPS, MODE_MANUAL, MODE_MARKERS, MODE_SERVER } from './constants';
/**
* @summary Create virtual tours by linking multiple panoramas
* @extends PSV.plugins.AbstractPlugin
* @memberof PSV.plugins
*/
export class VirtualTourPlugin extends AbstractPlugin {
static id = 'virtual-tour';
static EVENTS = EVENTS;
static MODE_CLIENT = MODE_CLIENT;
static MODE_SERVER = MODE_SERVER;
static MODE_3D = MODE_3D;
static MODE_MARKERS = MODE_MARKERS;
static MODE_MANUAL = MODE_MANUAL;
static MODE_GPS = MODE_GPS;
/**
* @param {PSV.Viewer} psv
* @param {PSV.plugins.VirtualTourPlugin.Options} [options]
*/
constructor(psv, options) {
super(psv);
/**
* @member {Object}
* @property {PSV.plugins.VirtualTourPlugin.Node} currentNode
* @property {PSV.Tooltip} currentTooltip
* @property {string} loadingNode
* @property {function} stopObserver
* @private
*/
this.prop = {
currentNode : null,
currentTooltip: null,
loadingNode : null,
stopObserver : null,
};
/**
* @type {Record<string, boolean | Promise>}
* @private
*/
this.preload = {};
/**
* @member {PSV.plugins.VirtualTourPlugin.Options}
* @private
*/
this.config = {
dataMode : MODE_CLIENT,
positionMode : MODE_MANUAL,
renderMode : MODE_3D,
preload : false,
rotateSpeed : '20rpm',
transition : CONSTANTS.DEFAULT_TRANSITION,
markerLatOffset: -0.1,
arrowPosition : 'bottom',
linksOnCompass : options?.renderMode === MODE_MARKERS,
...options,
markerStyle: {
...DEFAULT_MARKER,
...options?.markerStyle,
},
arrowStyle : {
...DEFAULT_ARROW,
...options?.arrowStyle,
},
};
/**
* @type {PSV.plugins.MarkersPlugin}
* @private
*/
this.markers = null;
/**
* @type {PSV.plugins.CompassPlugin}
* @private
*/
this.compass = null;
/**
* @type {PSV.plugins.GalleryPlugin}
* @private
*/
this.gallery = null;
/**
* @type {PSV.plugins.VirtualTourPlugin.AbstractDatasource}
*/
this.datasource = null;
/**
* @type {external:THREE.Group}
* @private
*/
this.arrowsGroup = null;
if (this.is3D()) {
this.arrowsGroup = new Group();
const localLight = new PointLight(0xffffff, 1, 0);
localLight.position.set(0, this.config.arrowPosition === 'bottom' ? 2 : -2, 0);
this.arrowsGroup.add(localLight);
}
}
/**
* @package
*/
init() {
super.init();
this.markers = this.psv.getPlugin('markers');
this.compass = this.psv.getPlugin('compass');
this.gallery = this.psv.getPlugin('gallery');
if (!this.is3D() && !this.markers) {
throw new PSVError('VirtualTour plugin requires the Markers plugin in markers mode.');
}
if (this.markers?.config.markers) {
utils.logWarn('No default markers can be configured on Markers plugin when using VirtualTour plugin. '
+ 'Consider defining `markers` on each tour node.');
delete this.markers.config.markers;
}
this.datasource = this.isServerSide() ? new ServerSideDatasource(this) : new ClientSideDatasource(this);
if (this.is3D()) {
this.psv.once(CONSTANTS.EVENTS.READY, () => {
this.__positionArrows();
this.psv.renderer.scene.add(this.arrowsGroup);
const ambientLight = new AmbientLight(0xffffff, 1);
this.psv.renderer.scene.add(ambientLight);
this.psv.needsUpdate();
});
this.psv.on(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.psv.on(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
this.psv.on(CONSTANTS.EVENTS.CLICK, this);
this.prop.stopObserver = this.psv.observeObjects(LINK_DATA, this);
}
else {
this.markers.on('select-marker', this);
}
if (this.isServerSide()) {
if (this.config.startNodeId) {
this.setCurrentNode(this.config.startNodeId);
}
}
else if (this.config.nodes) {
this.setNodes(this.config.nodes, this.config.startNodeId);
delete this.config.nodes;
}
}
/**
* @package
*/
destroy() {
if (this.markers) {
this.markers.off('select-marker', this);
}
if (this.arrowsGroup) {
this.psv.renderer.scene.remove(this.arrowsGroup);
}
this.psv.off(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.psv.off(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
this.psv.off(CONSTANTS.EVENTS.CLICK, this);
this.prop.stopObserver?.();
this.datasource.destroy();
delete this.preload;
delete this.datasource;
delete this.markers;
delete this.compass;
delete this.gallery;
delete this.arrowsGroup;
super.destroy();
}
handleEvent(e) {
let link;
switch (e.type) {
case 'select-marker':
link = e.args[0].data?.[LINK_DATA];
if (link) {
this.setCurrentNode(link.nodeId, link);
}
break;
case CONSTANTS.EVENTS.POSITION_UPDATED:
case CONSTANTS.EVENTS.ZOOM_UPDATED:
if (this.arrowsGroup) {
this.__positionArrows();
}
break;
case CONSTANTS.EVENTS.CLICK:
link = e.args[0].objects.find(o => o.userData[LINK_DATA])?.userData[LINK_DATA];
if (link) {
this.setCurrentNode(link.nodeId, link);
}
break;
case CONSTANTS.OBJECT_EVENTS.ENTER_OBJECT:
this.__onEnterObject(e.detail.object, e.detail.viewerPoint);
break;
case CONSTANTS.OBJECT_EVENTS.HOVER_OBJECT:
this.__onHoverObject(e.detail.object, e.detail.viewerPoint);
break;
case CONSTANTS.OBJECT_EVENTS.LEAVE_OBJECT:
this.__onLeaveObject(e.detail.object);
break;
default:
}
}
/**
* @summary Tests if running in server mode
* @return {boolean}
*/
isServerSide() {
return this.config.dataMode === MODE_SERVER;
}
/**
* @summary Tests if running in GPS mode
* @return {boolean}
*/
isGps() {
return this.config.positionMode === MODE_GPS;
}
/**
* @summary Tests if running in 3D mode
* @return {boolean}
*/
is3D() {
return this.config.renderMode === MODE_3D;
}
/**
* @summary Sets the nodes (client mode only)
* @param {PSV.plugins.VirtualTourPlugin.Node[]} nodes
* @param {string} [startNodeId]
* @throws {PSV.PSVError} when the configuration is incorrect
*/
setNodes(nodes, startNodeId) {
if (this.isServerSide()) {
throw new PSVError('Cannot set nodes in server side mode');
}
this.datasource.setNodes(nodes);
if (!startNodeId) {
startNodeId = nodes[0].id;
}
else if (!this.datasource.nodes[startNodeId]) {
startNodeId = nodes[0].id;
utils.logWarn(`startNodeId not found is provided nodes, resetted to ${startNodeId}`);
}
this.setCurrentNode(startNodeId);
if (this.gallery) {
this.gallery.setItems(
nodes.map(node => ({
id : node.id,
panorama : node.panorama,
name : node.name,
thumbnail: node.thumbnail,
options : {
caption : node.caption,
panoData : node.panoData,
sphereCorrection: node.sphereCorrection,
description : node.description,
},
})),
(id) => {
this.setCurrentNode(id);
this.gallery.hide();
}
);
}
}
/**
* @summary Changes the current node
* @param {string} nodeId
* @param {PSV.plugins.VirtualTourPlugin.NodeLink} [fromLink]
* @returns {Promise<boolean>} resolves false if the loading was aborted by another call
*/
setCurrentNode(nodeId, fromLink = null) {
if (nodeId === this.prop.currentNode?.id) {
return Promise.resolve(true);
}
this.psv.hideError();
this.prop.loadingNode = nodeId;
const fromNode = this.prop.currentNode;
const fromLinkPosition = fromNode && fromLink ? this.__getLinkPosition(fromNode, fromLink) : null;
return Promise.all([
// if this node is already preloading, wait for it
Promise.resolve(this.preload[nodeId])
.then(() => {
if (this.prop.loadingNode !== nodeId) {
throw utils.getAbortError();
}
return this.datasource.loadNode(nodeId);
}),
Promise.resolve(fromLinkPosition ? this.config.rotateSpeed : false)
.then((speed) => { // eslint-disable-line consistent-return
if (speed) {
return this.psv.animate({ ...fromLinkPosition, speed });
}
})
.then(() => {
this.psv.loader.show();
}),
])
.then(([node]) => {
if (this.prop.loadingNode !== nodeId) {
throw utils.getAbortError();
}
this.prop.currentNode = node;
if (this.prop.currentTooltip) {
this.prop.currentTooltip.hide();
this.prop.currentTooltip = null;
}
if (this.is3D()) {
this.arrowsGroup.remove(...this.arrowsGroup.children.filter(o => o.type === 'Mesh'));
}
this.markers?.clearMarkers();
this.compass?.clearHotspots();
return this.psv.setPanorama(node.panorama, {
transition : this.config.transition,
caption : node.caption,
description : node.description,
panoData : node.panoData,
sphereCorrection: node.sphereCorrection,
})
.then((completed) => {
if (!completed) {
throw utils.getAbortError();
}
});
})
.then(() => {
if (this.prop.loadingNode !== nodeId) {
throw utils.getAbortError();
}
const node = this.prop.currentNode;
if (node.markers) {
if (this.markers) {
this.markers.setMarkers(node.markers);
}
else {
utils.logWarn(`Node ${node.id} markers ignored because the plugin is not loaded.`);
}
}
this.__renderLinks(node);
this.__preload(node);
/**
* @event node-changed
* @memberof PSV.plugins.VirtualTourPlugin
* @summary Triggered when the current node is changed
* @param {string} nodeId
* @param {PSV.plugins.VirtualTourPlugin.NodeChangedData} data
*/
this.trigger(EVENTS.NODE_CHANGED, nodeId, {
fromNode,
fromLink,
fromLinkPosition,
});
this.prop.loadingNode = null;
return true;
})
.catch((err) => {
if (utils.isAbortError(err)) {
return false;
}
this.psv.showError(this.psv.config.lang.loadError);
this.psv.loader.hide();
this.psv.navbar.setCaption('');
this.prop.loadingNode = null;
throw err;
});
}
/**
* @summary Adds the links for the node
* @param {PSV.plugins.VirtualTourPlugin.Node} node
* @private
*/
__renderLinks(node) {
const positions = [];
node.links.forEach((link) => {
const position = this.__getLinkPosition(node, link);
positions.push(position);
if (this.is3D()) {
const mesh = new Mesh(ARROW_GEOM, new MeshLambertMaterial());
mesh.userData = { [LINK_DATA]: link, longitude: position.longitude };
mesh.rotation.order = 'YXZ';
mesh.rotateY(-position.longitude);
this.psv.dataHelper
.sphericalCoordsToVector3({ longitude: position.longitude, latitude: 0 }, mesh.position)
.multiplyScalar(1 / CONSTANTS.SPHERE_RADIUS);
const outlineMesh = new Mesh(ARROW_OUTLINE_GEOM, new MeshBasicMaterial({ side: BackSide }));
outlineMesh.position.copy(mesh.position);
outlineMesh.rotation.copy(mesh.rotation);
setMeshColor(mesh, link.arrowStyle?.color || this.config.arrowStyle.color);
setMeshColor(outlineMesh, link.arrowStyle?.outlineColor || this.config.arrowStyle.outlineColor);
this.arrowsGroup.add(mesh);
this.arrowsGroup.add(outlineMesh);
}
else {
if (this.isGps()) {
position.latitude += this.config.markerLatOffset;
}
this.markers.addMarker({
...this.config.markerStyle,
...link.markerStyle,
id : `tour-link-${link.nodeId}`,
tooltip : link.name,
visible : true,
hideList: true,
content : null,
data : { [LINK_DATA]: link },
...position,
}, false);
}
});
if (this.is3D()) {
this.__positionArrows();
}
else {
this.markers.renderMarkers();
}
if (this.config.linksOnCompass && this.compass) {
this.compass.setHotspots(positions);
}
}
/**
* @summary Computes the marker position for a link
* @param {PSV.plugins.VirtualTourPlugin.Node} node
* @param {PSV.plugins.VirtualTourPlugin.NodeLink} link
* @return {PSV.Position}
* @private
*/
__getLinkPosition(node, link) {
if (this.isGps()) {
const p1 = [MathUtils.degToRad(node.position[0]), MathUtils.degToRad(node.position[1])];
const p2 = [MathUtils.degToRad(link.position[0]), MathUtils.degToRad(link.position[1])];
const h1 = node.position[2] !== undefined ? node.position[2] : link.position[2] || 0;
const h2 = link.position[2] !== undefined ? link.position[2] : node.position[2] || 0;
let latitude = 0;
if (h1 !== h2) {
latitude = Math.atan((h2 - h1) / distance(p1, p2));
}
const longitude = bearing(p1, p2);
return { longitude, latitude };
}
else {
return this.psv.dataHelper.cleanPosition(link);
}
}
/**
* @private
*/
__onEnterObject(mesh, viewerPoint) {
const link = mesh.userData[LINK_DATA];
setMeshColor(mesh, link.arrowStyle?.hoverColor || this.config.arrowStyle.hoverColor);
if (link.name) {
this.prop.currentTooltip = this.psv.tooltip.create({
left : viewerPoint.x,
top : viewerPoint.y,
content: link.name,
});
}
this.psv.needsUpdate();
}
/**
* @private
*/
__onHoverObject(mesh, viewerPoint) {
if (this.prop.currentTooltip) {
this.prop.currentTooltip.move({
left: viewerPoint.x,
top : viewerPoint.y,
});
}
}
/**
* @private
*/
__onLeaveObject(mesh) {
const link = mesh.userData[LINK_DATA];
setMeshColor(mesh, link.arrowStyle?.color || this.config.arrowStyle.color);
if (this.prop.currentTooltip) {
this.prop.currentTooltip.hide();
this.prop.currentTooltip = null;
}
this.psv.needsUpdate();
}
/**
* @summary Updates to position of the group of arrows
* @private
*/
__positionArrows() {
this.arrowsGroup.position.copy(this.psv.prop.direction).multiplyScalar(0.5);
const s = this.config.arrowStyle.scale;
const f = s[1] + (s[0] - s[1]) * (this.psv.getZoomLevel() / 100);
const y = 2.5 - (this.psv.getZoomLevel() / 100) * 1.5;
this.arrowsGroup.position.y += this.config.arrowPosition === 'bottom' ? -y : y;
this.arrowsGroup.scale.set(f, f, f);
}
/**
* @summary Manage the preload of the linked panoramas
* @param {PSV.plugins.VirtualTourPlugin.Node} node
* @private
*/
__preload(node) {
if (!this.config.preload) {
return;
}
this.preload[node.id] = true;
this.prop.currentNode.links
.filter(link => !this.preload[link.nodeId])
.filter((link) => {
if (typeof this.config.preload === 'function') {
return this.config.preload(this.prop.currentNode, link);
}
else {
return true;
}
})
.forEach((link) => {
this.preload[link.nodeId] = this.datasource.loadNode(link.nodeId)
.then((linkNode) => {
return this.psv.textureLoader.preloadPanorama(linkNode.panorama);
})
.then(() => {
this.preload[link.nodeId] = true;
})
.catch(() => {
delete this.preload[link.nodeId];
});
});
}
}