photo-sphere-viewer
Version:
A JavaScript library to display Photo Sphere panoramas
762 lines (671 loc) • 20.9 kB
JavaScript
import { Group, MathUtils, Mesh, MeshBasicMaterial, PlaneGeometry, TextureLoader } from 'three';
import { CONSTANTS, PSVError, utils } from '../..';
import { MARKER_DATA, MARKER_TOOLTIP_TRIGGER, SVG_NS } from './constants';
import { getPolygonCenter, getPolylineCenter } from './utils';
/**
* @summary Types of marker
* @memberOf PSV.plugins.MarkersPlugin
* @enum {string}
* @constant
* @private
*/
const MARKER_TYPES = {
image : 'image',
imageLayer : 'imageLayer',
html : 'html',
polygonPx : 'polygonPx',
polygonRad : 'polygonRad',
polylinePx : 'polylinePx',
polylineRad: 'polylineRad',
square : 'square',
rect : 'rect',
circle : 'circle',
ellipse : 'ellipse',
path : 'path',
};
/**
* @typedef {Object} PSV.plugins.MarkersPlugin.Properties
* @summary Marker properties, see {@link https://photo-sphere-viewer.js.org/plugins/plugin-markers.html#markers-options}
*/
/**
* @summary Object representing a marker
* @memberOf PSV.plugins.MarkersPlugin
*/
export class Marker {
/**
* @param {PSV.plugins.MarkersPlugin.Properties} properties
* @param {PSV.Viewer} psv
* @throws {PSV.PSVError} when the configuration is incorrect
*/
constructor(properties, psv) {
if (!properties.id) {
throw new PSVError('missing marker id');
}
/**
* @member {PSV.Viewer}
* @readonly
* @protected
*/
this.psv = psv;
/**
* @member {string}
* @readonly
*/
this.id = properties.id;
/**
* @member {string}
* @readonly
*/
this.type = Marker.getType(properties, false);
/**
* @member {boolean}
* @protected
*/
this.visible = true;
/**
* @member {HTMLElement|SVGElement|THREE.Object3D}
* @readonly
*/
this.$el = null;
/**
* @summary Original configuration of the marker
* @member {PSV.plugins.MarkersPlugin.Properties}
* @readonly
*/
this.config = {};
/**
* @summary User data associated to the marker
* @member {any}
*/
this.data = undefined;
/**
* @summary Tooltip instance for this marker
* @member {PSV.components.Tooltip}
*/
this.tooltip = null;
/**
* @summary Computed properties
* @member {Object}
* @protected
* @property {boolean} dynamicSize
* @property {PSV.Point} anchor
* @property {boolean} visible - actually visible in the view
* @property {boolean} staticTooltip - the tooltip must always be shown
* @property {PSV.Position} position - position in spherical coordinates
* @property {PSV.Point} position2D - position in viewer coordinates
* @property {external:THREE.Vector3[]} positions3D - positions in 3D space
* @property {number} width
* @property {number} height
* @property {*} def
*/
this.props = {
dynamicSize : false,
anchor : null,
visible : false,
staticTooltip: false,
position : null,
position2D : null,
positions3D : null,
width : null,
height : null,
def : null,
};
/**
* @summary THREE file loader
* @type {THREE:TextureLoader}
* @private
*/
this.loader = null;
if (this.is3d()) {
this.loader = new TextureLoader();
if (this.psv.config.withCredentials) {
this.loader.setWithCredentials(true);
}
if (this.psv.config.requestHeaders && typeof this.psv.config.requestHeaders === 'object') {
this.loader.setRequestHeader(this.psv.config.requestHeaders);
}
}
// create element
if (this.isNormal()) {
this.$el = document.createElement('div');
}
else if (this.isPolygon()) {
this.$el = document.createElementNS(SVG_NS, 'polygon');
}
else if (this.isPolyline()) {
this.$el = document.createElementNS(SVG_NS, 'polyline');
}
else if (this.isSvg()) {
const svgType = this.type === 'square' ? 'rect' : this.type;
this.$el = document.createElementNS(SVG_NS, svgType);
}
if (!this.is3d()) {
this.$el.id = `psv-marker-${this.id}`;
this.$el[MARKER_DATA] = this;
}
this.update(properties);
}
/**
* @summary Destroys the marker
*/
destroy() {
delete this.$el[MARKER_DATA];
delete this.$el;
delete this.config;
delete this.props;
delete this.psv;
}
/**
* @summary Checks if it is a 3D marker (imageLayer)
* @returns {boolean}
*/
is3d() {
return this.type === MARKER_TYPES.imageLayer;
}
/**
* @summary Checks if it is a normal marker (image or html)
* @returns {boolean}
*/
isNormal() {
return this.type === MARKER_TYPES.image
|| this.type === MARKER_TYPES.html;
}
/**
* @summary Checks if it is a polygon/polyline marker
* @returns {boolean}
*/
isPoly() {
return this.isPolygon()
|| this.isPolyline();
}
/**
* @summary Checks if it is a polygon/polyline using pixel coordinates
* @returns {boolean}
*/
isPolyPx() {
return this.type === MARKER_TYPES.polygonPx
|| this.type === MARKER_TYPES.polylinePx;
}
/**
* @summary Checks if it is a polygon/polyline using radian coordinates
* @returns {boolean}
*/
isPolyRad() {
return this.type === MARKER_TYPES.polygonRad
|| this.type === MARKER_TYPES.polylineRad;
}
/**
* @summary Checks if it is a polygon marker
* @returns {boolean}
*/
isPolygon() {
return this.type === MARKER_TYPES.polygonPx
|| this.type === MARKER_TYPES.polygonRad;
}
/**
* @summary Checks if it is a polyline marker
* @returns {boolean}
*/
isPolyline() {
return this.type === MARKER_TYPES.polylinePx
|| this.type === MARKER_TYPES.polylineRad;
}
/**
* @summary Checks if it is an SVG marker
* @returns {boolean}
*/
isSvg() {
return this.type === MARKER_TYPES.square
|| this.type === MARKER_TYPES.rect
|| this.type === MARKER_TYPES.circle
|| this.type === MARKER_TYPES.ellipse
|| this.type === MARKER_TYPES.path;
}
/**
* @summary Computes marker scale from zoom level
* @param {number} zoomLevel
* @param {PSV.Position} position
* @returns {number}
*/
getScale(zoomLevel, position) {
if (!this.config.scale) {
return 1;
}
if (typeof this.config.scale === 'function') {
return this.config.scale(zoomLevel, position);
}
let scale = 1;
if (Array.isArray(this.config.scale.zoom)) {
const bounds = this.config.scale.zoom;
scale *= bounds[0] + (bounds[1] - bounds[0]) * CONSTANTS.EASINGS.inQuad(zoomLevel / 100);
}
if (Array.isArray(this.config.scale.longitude)) {
const bounds = this.config.scale.longitude;
const halfFov = MathUtils.degToRad(this.psv.prop.hFov) / 2;
const arc = Math.abs(utils.getShortestArc(this.props.position.longitude, position.longitude));
scale *= bounds[1] + (bounds[0] - bounds[1]) * CONSTANTS.EASINGS.outQuad(Math.max(0, (halfFov - arc) / halfFov));
}
return scale;
}
/**
* @summary Returns the markers list content for the marker, it can be either :
* - the `listContent`
* - the `tooltip.content`
* - the `html`
* - the `id`
* @returns {*}
*/
getListContent() {
if (this.config.listContent) {
return this.config.listContent;
}
else if (this.config.tooltip.content) {
return this.config.tooltip.content;
}
else if (this.config.html) {
return this.config.html;
}
else {
return this.id;
}
}
/**
* @summary Display the tooltip of this marker
* @param {{clientX: number, clientY: number}} [mousePosition]
*/
showTooltip(mousePosition) {
if (this.props.visible && this.config.tooltip.content && this.props.position2D) {
const config = {
...this.config.tooltip,
data: this,
};
if (this.isPoly()) {
if (mousePosition) {
const viewerPos = utils.getPosition(this.psv.container);
config.top = mousePosition.clientY - viewerPos.top;
config.left = mousePosition.clientX - viewerPos.left;
config.box = { // separate the tooltip from the cursor
width : 20,
height: 20,
};
}
else {
config.top = this.props.position2D.y;
config.left = this.props.position2D.x;
}
}
else {
config.top = this.props.position2D.y + this.props.height / 2;
config.left = this.props.position2D.x + this.props.width / 2;
config.box = {
width : this.props.width,
height: this.props.height,
};
}
if (this.tooltip) {
this.tooltip.move(config);
}
else {
this.tooltip = this.psv.tooltip.create(config);
}
}
}
/**
* @summary Recompute the position of the tooltip
*/
refreshTooltip() {
if (this.tooltip) {
this.showTooltip();
}
}
/**
* @summary Hides the tooltip of this marker
*/
hideTooltip() {
if (this.tooltip) {
this.tooltip.hide();
this.tooltip = null;
}
}
/**
* @summary Updates the marker with new properties
* @param {PSV.plugins.MarkersPlugin.Properties} properties
* @throws {PSV.PSVError} when the configuration is incorrect
*/
update(properties) {
const newType = Marker.getType(properties, true);
if (newType !== undefined && newType !== this.type) {
throw new PSVError('cannot change marker type');
}
utils.deepmerge(this.config, properties);
if (typeof this.config.tooltip === 'string') {
this.config.tooltip = { content: this.config.tooltip };
}
if (!this.config.tooltip) {
this.config.tooltip = {};
}
if (!this.config.tooltip.trigger) {
this.config.tooltip.trigger = MARKER_TOOLTIP_TRIGGER.hover;
}
this.data = this.config.data;
this.visible = this.config.visible !== false;
if (!this.is3d()) {
// reset CSS class
if (this.isNormal()) {
this.$el.className = 'psv-marker psv-marker--normal';
}
else {
this.$el.setAttribute('class', 'psv-marker psv-marker--svg');
}
// add CSS classes
if (this.config.className) {
utils.addClasses(this.$el, this.config.className);
}
if (this.config.tooltip) {
this.$el.classList.add('psv-marker--has-tooltip');
}
if (this.config.content) {
this.$el.classList.add('psv-marler--has-content');
}
// apply style
this.$el.style.opacity = this.config.opacity ?? 1;
if (this.config.style) {
utils.deepmerge(this.$el.style, this.config.style);
}
}
// parse anchor
this.props.anchor = utils.parsePosition(this.config.anchor);
// clean scale
if (this.config.scale && Array.isArray(this.config.scale)) {
this.config.scale = { zoom: this.config.scale };
}
if (this.isNormal()) {
this.__updateNormal();
}
else if (this.isPoly()) {
this.__updatePoly();
}
else if (this.isSvg()) {
this.__updateSvg();
}
else if (this.is3d()) {
this.__update3d();
}
}
/**
* @summary Updates a normal marker
* @private
*/
__updateNormal() {
if (!utils.isExtendedPosition(this.config)) {
throw new PSVError('missing marker position, latitude/longitude or x/y');
}
if (this.config.image && (!this.config.width || !this.config.height)) {
throw new PSVError('missing marker width/height');
}
if (this.config.width && this.config.height) {
this.props.dynamicSize = false;
this.props.width = this.config.width;
this.props.height = this.config.height;
this.$el.style.width = this.config.width + 'px';
this.$el.style.height = this.config.height + 'px';
}
else {
this.props.dynamicSize = true;
}
if (this.config.image) {
this.props.def = this.config.image;
this.$el.style.backgroundImage = `url(${this.config.image})`;
}
else if (this.config.html) {
this.props.def = this.config.html;
this.$el.innerHTML = this.config.html;
}
// set anchor
this.$el.style.transformOrigin = `${this.props.anchor.x * 100}% ${this.props.anchor.y * 100}%`;
// convert texture coordinates to spherical coordinates
this.props.position = this.psv.dataHelper.cleanPosition(this.config);
// compute x/y/z position
this.props.positions3D = [this.psv.dataHelper.sphericalCoordsToVector3(this.props.position)];
}
/**
* @summary Updates an SVG marker
* @private
*/
__updateSvg() {
if (!utils.isExtendedPosition(this.config)) {
throw new PSVError('missing marker position, latitude/longitude or x/y');
}
this.props.dynamicSize = true;
// set content
switch (this.type) {
case MARKER_TYPES.square:
this.props.def = {
x : 0,
y : 0,
width : this.config.square,
height: this.config.square,
};
break;
case MARKER_TYPES.rect:
if (Array.isArray(this.config.rect)) {
this.props.def = {
x : 0,
y : 0,
width : this.config.rect[0],
height: this.config.rect[1],
};
}
else {
this.props.def = {
x : 0,
y : 0,
width : this.config.rect.width,
height: this.config.rect.height,
};
}
break;
case MARKER_TYPES.circle:
this.props.def = {
cx: this.config.circle,
cy: this.config.circle,
r : this.config.circle,
};
break;
case MARKER_TYPES.ellipse:
if (Array.isArray(this.config.ellipse)) {
this.props.def = {
cx: this.config.ellipse[0],
cy: this.config.ellipse[1],
rx: this.config.ellipse[0],
ry: this.config.ellipse[1],
};
}
else {
this.props.def = {
cx: this.config.ellipse.rx,
cy: this.config.ellipse.ry,
rx: this.config.ellipse.rx,
ry: this.config.ellipse.ry,
};
}
break;
case MARKER_TYPES.path:
this.props.def = {
d: this.config.path,
};
break;
// no default
}
utils.each(this.props.def, (value, prop) => {
this.$el.setAttributeNS(null, prop, value);
});
// set style
if (this.config.svgStyle) {
utils.each(this.config.svgStyle, (value, prop) => {
this.$el.setAttributeNS(null, utils.dasherize(prop), value);
});
}
else {
this.$el.setAttributeNS(null, 'fill', 'rgba(0,0,0,0.5)');
}
// convert texture coordinates to spherical coordinates
this.props.position = this.psv.dataHelper.cleanPosition(this.config);
// compute x/y/z position
this.props.positions3D = [this.psv.dataHelper.sphericalCoordsToVector3(this.props.position)];
}
/**
* @summary Updates a polygon marker
* @private
*/
__updatePoly() {
this.props.dynamicSize = true;
// set style
if (this.config.svgStyle) {
utils.each(this.config.svgStyle, (value, prop) => {
this.$el.setAttributeNS(null, utils.dasherize(prop), value);
});
if (this.isPolyline() && !this.config.svgStyle.fill) {
this.$el.setAttributeNS(null, 'fill', 'none');
}
}
else if (this.isPolygon()) {
this.$el.setAttributeNS(null, 'fill', 'rgba(0,0,0,0.5)');
}
else if (this.isPolyline()) {
this.$el.setAttributeNS(null, 'fill', 'none');
this.$el.setAttributeNS(null, 'stroke', 'rgb(0,0,0)');
}
// fold arrays: [1,2,3,4] => [[1,2],[3,4]]
const actualPoly = this.config.polygonPx || this.config.polygonRad || this.config.polylinePx || this.config.polylineRad;
if (!Array.isArray(actualPoly[0])) {
for (let i = 0; i < actualPoly.length; i++) {
actualPoly.splice(i, 2, [actualPoly[i], actualPoly[i + 1]]);
}
}
// convert texture coordinates to spherical coordinates
if (this.isPolyPx()) {
this.props.def = actualPoly.map((coord) => {
const sphericalCoords = this.psv.dataHelper.textureCoordsToSphericalCoords({ x: coord[0], y: coord[1] });
return [sphericalCoords.longitude, sphericalCoords.latitude];
});
}
// clean angles
else {
this.props.def = actualPoly.map((coord) => {
return [utils.parseAngle(coord[0]), utils.parseAngle(coord[1], true)];
});
}
const centroid = this.isPolygon()
? getPolygonCenter(this.props.def)
: getPolylineCenter(this.props.def);
this.props.position = {
longitude: centroid[0],
latitude : centroid[1],
};
// compute x/y/z positions
this.props.positions3D = this.props.def.map((coord) => {
return this.psv.dataHelper.sphericalCoordsToVector3({ longitude: coord[0], latitude: coord[1] });
});
}
/**
* @summary Updates a 3D marker
* @private
*/
__update3d() {
if (!this.config.width || !this.config.height) {
throw new PSVError('missing marker width/height');
}
this.props.dynamicSize = false;
this.props.width = this.config.width;
this.props.height = this.config.height;
// convert texture coordinates to spherical coordinates
this.props.position = this.psv.dataHelper.cleanPosition(this.config);
// compute x/y/z position
this.props.positions3D = [this.psv.dataHelper.sphericalCoordsToVector3(this.props.position)];
switch (this.type) {
case MARKER_TYPES.imageLayer:
if (!this.$el) {
const material = new MeshBasicMaterial({
transparent: true,
opacity : this.config.opacity ?? 1,
depthTest : false,
});
const geometry = new PlaneGeometry(1, 1);
const mesh = new Mesh(geometry, material);
mesh.userData = { [MARKER_DATA]: this };
this.$el = new Group().add(mesh);
// overwrite the visible property to be tied to the Marker instance
// and do it without context bleed
Object.defineProperty(this.$el, 'visible', {
enumerable: true,
get : function () {
return this.children[0].userData[MARKER_DATA].visible;
},
set : function (visible) {
this.children[0].userData[MARKER_DATA].visible = visible;
},
});
}
if (this.props.def !== this.config.imageLayer) {
if (this.psv.config.requestHeaders && typeof this.psv.config.requestHeaders === 'function') {
this.loader.setRequestHeader(this.psv.config.requestHeaders(this.config.imageLayer));
}
this.$el.children[0].material.map = this.loader.load(this.config.imageLayer, (texture) => {
texture.anisotropy = 4;
this.psv.needsUpdate();
});
this.props.def = this.config.imageLayer;
}
this.$el.children[0].position.set(
this.props.anchor.x - 0.5,
this.props.anchor.y - 0.5,
0
);
this.$el.position.copy(this.props.positions3D[0]);
switch (this.config.orientation) {
case 'horizontal':
this.$el.lookAt(0, this.$el.position.y, 0);
this.$el.rotateX(this.props.position.latitude < 0 ? -Math.PI / 2 : Math.PI / 2);
break;
case 'vertical-left':
this.$el.lookAt(0, 0, 0);
this.$el.rotateY(-Math.PI * 0.4);
break;
case 'vertical-right':
this.$el.lookAt(0, 0, 0);
this.$el.rotateY(Math.PI * 0.4);
break;
default:
this.$el.lookAt(0, 0, 0);
break;
}
// 100 is magic number that gives a coherent size at default zoom level
this.$el.scale.set(this.config.width / 100, this.config.height / 100, 1);
break;
// no default
}
}
/**
* @summary Determines the type of a marker by the available properties
* @param {Marker.Properties} properties
* @param {boolean} [allowNone=false]
* @returns {string}
* @throws {PSV.PSVError} when the marker's type cannot be found
*/
static getType(properties, allowNone = false) {
const found = [];
utils.each(MARKER_TYPES, (type) => {
if (properties[type]) {
found.push(type);
}
});
if (found.length === 0 && !allowNone) {
throw new PSVError(`missing marker content, either ${Object.keys(MARKER_TYPES).join(', ')}`);
}
else if (found.length > 1) {
throw new PSVError(`multiple marker content, either ${Object.keys(MARKER_TYPES).join(', ')}`);
}
return found[0];
}
}