UNPKG

pannellum

Version:

Pannellum is a lightweight, free, and open source panorama viewer for the web.

1,432 lines (1,281 loc) 106 kB
/* * Pannellum - An HTML5 based Panorama Viewer * Copyright (c) 2011-2019 Matthew Petroff * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ window.pannellum = (function(window, document, undefined) { 'use strict'; /** * Creates a new panorama viewer. * @constructor * @param {HTMLElement|string} container - The container (div) element for the * viewer, or its ID. * @param {Object} initialConfig - Inital configuration for viewer. */ function Viewer(container, initialConfig) { var _this = this; // Declare variables var config, renderer, preview, isUserInteracting = false, latestInteraction = Date.now(), onPointerDownPointerX = 0, onPointerDownPointerY = 0, onPointerDownPointerDist = -1, onPointerDownYaw = 0, onPointerDownPitch = 0, keysDown = new Array(10), fullscreenActive = false, loaded, error = false, isTimedOut = false, listenersAdded = false, panoImage, prevTime, speed = {'yaw': 0, 'pitch': 0, 'hfov': 0}, animating = false, orientation = false, orientationYawOffset = 0, autoRotateStart, autoRotateSpeed = 0, origHfov, origPitch, animatedMove = {}, externalEventListeners = {}, specifiedPhotoSphereExcludes = [], update = false, // Should we update when still to render dynamic content eps = 1e-6, hotspotsCreated = false, destroyed = false; var defaultConfig = { hfov: 100, minHfov: 50, multiResMinHfov: false, maxHfov: 120, pitch: 0, minPitch: undefined, maxPitch: undefined, yaw: 0, minYaw: -180, maxYaw: 180, roll: 0, haov: 360, vaov: 180, vOffset: 0, autoRotate: false, autoRotateInactivityDelay: -1, autoRotateStopDelay: undefined, type: 'equirectangular', northOffset: 0, showFullscreenCtrl: true, dynamic: false, dynamicUpdate: false, doubleClickZoom: true, keyboardZoom: true, mouseZoom: true, showZoomCtrl: true, autoLoad: false, showControls: true, orientationOnByDefault: false, hotSpotDebug: false, backgroundColor: [0, 0, 0], avoidShowingBackground: false, animationTimingFunction: timingFunction, draggable: true, disableKeyboardCtrl: false, crossOrigin: 'anonymous', touchPanSpeedCoeffFactor: 1, capturedKeyNumbers: [16, 17, 27, 37, 38, 39, 40, 61, 65, 68, 83, 87, 107, 109, 173, 187, 189], friction: 0.15 }; // Translatable / configurable strings // Some strings contain '%s', which is a placeholder for inserted values // When setting strings in external configuration, `\n` should be used instead of `<br>` to insert line breaks defaultConfig.strings = { // Labels loadButtonLabel: 'Click to<br>Load<br>Panorama', loadingLabel: 'Loading...', bylineLabel: 'by %s', // One substitution: author // Errors noPanoramaError: 'No panorama image was specified.', fileAccessError: 'The file %s could not be accessed.', // One substitution: file URL malformedURLError: 'There is something wrong with the panorama URL.', iOS8WebGLError: "Due to iOS 8's broken WebGL implementation, only " + "progressive encoded JPEGs work for your device (this " + "panorama uses standard encoding).", genericWebGLError: 'Your browser does not have the necessary WebGL support to display this panorama.', textureSizeError: 'This panorama is too big for your device! It\'s ' + '%spx wide, but your device only supports images up to ' + '%spx wide. Try another device.' + ' (If you\'re the author, try scaling down the image.)', // Two substitutions: image width, max image width unknownError: 'Unknown error. Check developer console.', }; // Initialize container container = typeof container === 'string' ? document.getElementById(container) : container; container.classList.add('pnlm-container'); container.tabIndex = 0; // Create container for ui var uiContainer = document.createElement('div'); uiContainer.className = 'pnlm-ui'; container.appendChild(uiContainer); // Create container for renderer var renderContainer = document.createElement('div'); renderContainer.className = 'pnlm-render-container'; container.appendChild(renderContainer); var dragFix = document.createElement('div'); dragFix.className = 'pnlm-dragfix'; uiContainer.appendChild(dragFix); // Display about information on right click var aboutMsg = document.createElement('span'); aboutMsg.className = 'pnlm-about-msg'; aboutMsg.innerHTML = '<a href="https://pannellum.org/" target="_blank">Pannellum</a>'; uiContainer.appendChild(aboutMsg); dragFix.addEventListener('contextmenu', aboutMessage); // Create info display var infoDisplay = {}; // Hot spot debug indicator var hotSpotDebugIndicator = document.createElement('div'); hotSpotDebugIndicator.className = 'pnlm-sprite pnlm-hot-spot-debug-indicator'; uiContainer.appendChild(hotSpotDebugIndicator); // Panorama info infoDisplay.container = document.createElement('div'); infoDisplay.container.className = 'pnlm-panorama-info'; infoDisplay.title = document.createElement('div'); infoDisplay.title.className = 'pnlm-title-box'; infoDisplay.container.appendChild(infoDisplay.title); infoDisplay.author = document.createElement('div'); infoDisplay.author.className = 'pnlm-author-box'; infoDisplay.container.appendChild(infoDisplay.author); uiContainer.appendChild(infoDisplay.container); // Load box infoDisplay.load = {}; infoDisplay.load.box = document.createElement('div'); infoDisplay.load.box.className = 'pnlm-load-box'; infoDisplay.load.boxp = document.createElement('p'); infoDisplay.load.box.appendChild(infoDisplay.load.boxp); infoDisplay.load.lbox = document.createElement('div'); infoDisplay.load.lbox.className = 'pnlm-lbox'; infoDisplay.load.lbox.innerHTML = '<div class="pnlm-loading"></div>'; infoDisplay.load.box.appendChild(infoDisplay.load.lbox); infoDisplay.load.lbar = document.createElement('div'); infoDisplay.load.lbar.className = 'pnlm-lbar'; infoDisplay.load.lbarFill = document.createElement('div'); infoDisplay.load.lbarFill.className = 'pnlm-lbar-fill'; infoDisplay.load.lbar.appendChild(infoDisplay.load.lbarFill); infoDisplay.load.box.appendChild(infoDisplay.load.lbar); infoDisplay.load.msg = document.createElement('p'); infoDisplay.load.msg.className = 'pnlm-lmsg'; infoDisplay.load.box.appendChild(infoDisplay.load.msg); uiContainer.appendChild(infoDisplay.load.box); // Error message infoDisplay.errorMsg = document.createElement('div'); infoDisplay.errorMsg.className = 'pnlm-error-msg pnlm-info-box'; uiContainer.appendChild(infoDisplay.errorMsg); // Create controls var controls = {}; controls.container = document.createElement('div'); controls.container.className = 'pnlm-controls-container'; uiContainer.appendChild(controls.container); // Load button controls.load = document.createElement('div'); controls.load.className = 'pnlm-load-button'; controls.load.addEventListener('click', function() { processOptions(); load(); }); uiContainer.appendChild(controls.load); // Zoom controls controls.zoom = document.createElement('div'); controls.zoom.className = 'pnlm-zoom-controls pnlm-controls'; controls.zoomIn = document.createElement('div'); controls.zoomIn.className = 'pnlm-zoom-in pnlm-sprite pnlm-control'; controls.zoomIn.addEventListener('click', zoomIn); controls.zoom.appendChild(controls.zoomIn); controls.zoomOut = document.createElement('div'); controls.zoomOut.className = 'pnlm-zoom-out pnlm-sprite pnlm-control'; controls.zoomOut.addEventListener('click', zoomOut); controls.zoom.appendChild(controls.zoomOut); controls.container.appendChild(controls.zoom); // Fullscreen toggle controls.fullscreen = document.createElement('div'); controls.fullscreen.addEventListener('click', toggleFullscreen); controls.fullscreen.className = 'pnlm-fullscreen-toggle-button pnlm-sprite pnlm-fullscreen-toggle-button-inactive pnlm-controls pnlm-control'; if (document.fullscreenEnabled || document.mozFullScreenEnabled || document.webkitFullscreenEnabled || document.msFullscreenEnabled) controls.container.appendChild(controls.fullscreen); // Device orientation toggle controls.orientation = document.createElement('div'); controls.orientation.addEventListener('click', function(e) { if (orientation) stopOrientation(); else startOrientation(); }); controls.orientation.addEventListener('mousedown', function(e) {e.stopPropagation();}); controls.orientation.addEventListener('touchstart', function(e) {e.stopPropagation();}); controls.orientation.addEventListener('pointerdown', function(e) {e.stopPropagation();}); controls.orientation.className = 'pnlm-orientation-button pnlm-orientation-button-inactive pnlm-sprite pnlm-controls pnlm-control'; var orientationSupport = false; if (window.DeviceOrientationEvent && location.protocol == 'https:' && navigator.userAgent.toLowerCase().indexOf('mobi') >= 0) { // This user agent check is here because there's no way to check if a // device has an inertia measurement unit. We used to be able to check if a // DeviceOrientationEvent had non-null values, but with iOS 13 requiring a // permission prompt to access such events, this is no longer possible. controls.container.appendChild(controls.orientation); orientationSupport = true; } // Compass var compass = document.createElement('div'); compass.className = 'pnlm-compass pnlm-controls pnlm-control'; uiContainer.appendChild(compass); // Load and process configuration if (initialConfig.firstScene) { // Activate first scene if specified in URL mergeConfig(initialConfig.firstScene); } else if (initialConfig.default && initialConfig.default.firstScene) { // Activate first scene if specified in file mergeConfig(initialConfig.default.firstScene); } else { mergeConfig(null); } processOptions(true); /** * Initializes viewer. * @private */ function init() { // Display an error for IE 9 as it doesn't work but also doesn't otherwise // show an error (older versions don't work at all) // Based on: http://stackoverflow.com/a/10965203 var div = document.createElement("div"); div.innerHTML = "<!--[if lte IE 9]><i></i><![endif]-->"; if (div.getElementsByTagName("i").length == 1) { anError(); return; } origHfov = config.hfov; origPitch = config.pitch; var i, p; if (config.type == 'cubemap') { panoImage = []; for (i = 0; i < 6; i++) { panoImage.push(new Image()); panoImage[i].crossOrigin = config.crossOrigin; } infoDisplay.load.lbox.style.display = 'block'; infoDisplay.load.lbar.style.display = 'none'; } else if (config.type == 'multires') { var c = JSON.parse(JSON.stringify(config.multiRes)); // Deep copy // Avoid "undefined" in path, check (optional) multiRes.basePath, too // Use only multiRes.basePath if it's an absolute URL if (config.basePath && config.multiRes.basePath && !(/^(?:[a-z]+:)?\/\//i.test(config.multiRes.basePath))) { c.basePath = config.basePath + config.multiRes.basePath; } else if (config.multiRes.basePath) { c.basePath = config.multiRes.basePath; } else if(config.basePath) { c.basePath = config.basePath; } panoImage = c; } else { if (config.dynamic === true) { panoImage = config.panorama; } else { if (config.panorama === undefined) { anError(config.strings.noPanoramaError); return; } panoImage = new Image(); } } // Configure image loading if (config.type == 'cubemap') { // Quick loading counter for synchronous loading var itemsToLoad = 6; var onLoad = function() { itemsToLoad--; if (itemsToLoad === 0) { onImageLoad(); } }; var onError = function(e) { var a = document.createElement('a'); a.href = e.target.src; a.textContent = a.href; anError(config.strings.fileAccessError.replace('%s', a.outerHTML)); }; for (i = 0; i < panoImage.length; i++) { p = config.cubeMap[i]; if (p == "null") { // support partial cubemap image with explicitly empty faces console.log('Will use background instead of missing cubemap face ' + i); onLoad(); } else { if (config.basePath && !absoluteURL(p)) { p = config.basePath + p; } panoImage[i].onload = onLoad; panoImage[i].onerror = onError; panoImage[i].src = sanitizeURL(p); } } } else if (config.type == 'multires') { onImageLoad(); } else { p = ''; if (config.basePath) { p = config.basePath; } if (config.dynamic !== true) { // Still image p = absoluteURL(config.panorama) ? config.panorama : p + config.panorama; panoImage.onload = function() { window.URL.revokeObjectURL(this.src); // Clean up onImageLoad(); }; var xhr = new XMLHttpRequest(); xhr.onloadend = function() { if (xhr.status != 200) { // Display error if image can't be loaded var a = document.createElement('a'); a.href = p; a.textContent = a.href; anError(config.strings.fileAccessError.replace('%s', a.outerHTML)); } var img = this.response; parseGPanoXMP(img); infoDisplay.load.msg.innerHTML = ''; }; xhr.onprogress = function(e) { if (e.lengthComputable) { // Display progress var percent = e.loaded / e.total * 100; infoDisplay.load.lbarFill.style.width = percent + '%'; var unit, numerator, denominator; if (e.total > 1e6) { unit = 'MB'; numerator = (e.loaded / 1e6).toFixed(2); denominator = (e.total / 1e6).toFixed(2); } else if (e.total > 1e3) { unit = 'kB'; numerator = (e.loaded / 1e3).toFixed(1); denominator = (e.total / 1e3).toFixed(1); } else { unit = 'B'; numerator = e.loaded; denominator = e.total; } infoDisplay.load.msg.innerHTML = numerator + ' / ' + denominator + ' ' + unit; } else { // Display loading spinner infoDisplay.load.lbox.style.display = 'block'; infoDisplay.load.lbar.style.display = 'none'; } }; try { xhr.open('GET', p, true); } catch (e) { // Malformed URL anError(config.strings.malformedURLError); } xhr.responseType = 'blob'; xhr.setRequestHeader('Accept', 'image/*,*/*;q=0.9'); xhr.withCredentials = config.crossOrigin === 'use-credentials'; xhr.send(); } } if (config.draggable) uiContainer.classList.add('pnlm-grab'); uiContainer.classList.remove('pnlm-grabbing'); // Properly handle switching to dynamic scenes update = config.dynamicUpdate === true; if (config.dynamic && update) { panoImage = config.panorama; onImageLoad(); } } /** * Test if URL is absolute or relative. * @private * @param {string} url - URL to test * @returns {boolean} True if absolute, else false */ function absoluteURL(url) { // From http://stackoverflow.com/a/19709846 return new RegExp('^(?:[a-z]+:)?//', 'i').test(url) || url[0] == '/' || url.slice(0, 5) == 'blob:'; } /** * Create renderer and initialize event listeners once image is loaded. * @private */ function onImageLoad() { if (!renderer) renderer = new libpannellum.renderer(renderContainer); // Only add event listeners once if (!listenersAdded) { listenersAdded = true; dragFix.addEventListener('mousedown', onDocumentMouseDown, false); document.addEventListener('mousemove', onDocumentMouseMove, false); document.addEventListener('mouseup', onDocumentMouseUp, false); if (config.mouseZoom) { uiContainer.addEventListener('mousewheel', onDocumentMouseWheel, false); uiContainer.addEventListener('DOMMouseScroll', onDocumentMouseWheel, false); } if (config.doubleClickZoom) { dragFix.addEventListener('dblclick', onDocumentDoubleClick, false); } container.addEventListener('mozfullscreenchange', onFullScreenChange, false); container.addEventListener('webkitfullscreenchange', onFullScreenChange, false); container.addEventListener('msfullscreenchange', onFullScreenChange, false); container.addEventListener('fullscreenchange', onFullScreenChange, false); window.addEventListener('resize', onDocumentResize, false); window.addEventListener('orientationchange', onDocumentResize, false); if (!config.disableKeyboardCtrl) { container.addEventListener('keydown', onDocumentKeyPress, false); container.addEventListener('keyup', onDocumentKeyUp, false); container.addEventListener('blur', clearKeys, false); } document.addEventListener('mouseleave', onDocumentMouseUp, false); if (document.documentElement.style.pointerAction === '' && document.documentElement.style.touchAction === '') { dragFix.addEventListener('pointerdown', onDocumentPointerDown, false); dragFix.addEventListener('pointermove', onDocumentPointerMove, false); dragFix.addEventListener('pointerup', onDocumentPointerUp, false); dragFix.addEventListener('pointerleave', onDocumentPointerUp, false); } else { dragFix.addEventListener('touchstart', onDocumentTouchStart, false); dragFix.addEventListener('touchmove', onDocumentTouchMove, false); dragFix.addEventListener('touchend', onDocumentTouchEnd, false); } // Deal with MS pointer events if (window.navigator.pointerEnabled) container.style.touchAction = 'none'; } renderInit(); setHfov(config.hfov); // possibly adapt hfov after configuration and canvas is complete; prevents empty space on top or bottom by zomming out too much setTimeout(function(){isTimedOut = true;}, 500); } /** * Parses Google Photo Sphere XMP Metadata. * https://developers.google.com/photo-sphere/metadata/ * @private * @param {Image} image - Image to read XMP metadata from. */ function parseGPanoXMP(image) { var reader = new FileReader(); reader.addEventListener('loadend', function() { var img = reader.result; // This awful browser specific test exists because iOS 8 does not work // with non-progressive encoded JPEGs. if (navigator.userAgent.toLowerCase().match(/(iphone|ipod|ipad).* os 8_/)) { var flagIndex = img.indexOf('\xff\xc2'); if (flagIndex < 0 || flagIndex > 65536) anError(config.strings.iOS8WebGLError); } var start = img.indexOf('<x:xmpmeta'); if (start > -1 && config.ignoreGPanoXMP !== true) { var xmpData = img.substring(start, img.indexOf('</x:xmpmeta>') + 12); // Extract the requested tag from the XMP data var getTag = function(tag) { var result; if (xmpData.indexOf(tag + '="') >= 0) { result = xmpData.substring(xmpData.indexOf(tag + '="') + tag.length + 2); result = result.substring(0, result.indexOf('"')); } else if (xmpData.indexOf(tag + '>') >= 0) { result = xmpData.substring(xmpData.indexOf(tag + '>') + tag.length + 1); result = result.substring(0, result.indexOf('<')); } if (result !== undefined) { return Number(result); } return null; }; // Relevant XMP data var xmp = { fullWidth: getTag('GPano:FullPanoWidthPixels'), croppedWidth: getTag('GPano:CroppedAreaImageWidthPixels'), fullHeight: getTag('GPano:FullPanoHeightPixels'), croppedHeight: getTag('GPano:CroppedAreaImageHeightPixels'), topPixels: getTag('GPano:CroppedAreaTopPixels'), heading: getTag('GPano:PoseHeadingDegrees'), horizonPitch: getTag('GPano:PosePitchDegrees'), horizonRoll: getTag('GPano:PoseRollDegrees') }; if (xmp.fullWidth !== null && xmp.croppedWidth !== null && xmp.fullHeight !== null && xmp.croppedHeight !== null && xmp.topPixels !== null) { // Set up viewer using GPano XMP data if (specifiedPhotoSphereExcludes.indexOf('haov') < 0) config.haov = xmp.croppedWidth / xmp.fullWidth * 360; if (specifiedPhotoSphereExcludes.indexOf('vaov') < 0) config.vaov = xmp.croppedHeight / xmp.fullHeight * 180; if (specifiedPhotoSphereExcludes.indexOf('vOffset') < 0) config.vOffset = ((xmp.topPixels + xmp.croppedHeight / 2) / xmp.fullHeight - 0.5) * -180; if (xmp.heading !== null && specifiedPhotoSphereExcludes.indexOf('northOffset') < 0) { // TODO: make sure this works correctly for partial panoramas config.northOffset = xmp.heading; if (config.compass !== false) { config.compass = true; } } if (xmp.horizonPitch !== null && xmp.horizonRoll !== null) { if (specifiedPhotoSphereExcludes.indexOf('horizonPitch') < 0) config.horizonPitch = xmp.horizonPitch; if (specifiedPhotoSphereExcludes.indexOf('horizonRoll') < 0) config.horizonRoll = xmp.horizonRoll; } // TODO: add support for initial view settings } } // Load panorama panoImage.src = window.URL.createObjectURL(image); }); if (reader.readAsBinaryString !== undefined) reader.readAsBinaryString(image); else reader.readAsText(image); } /** * Displays an error message. * @private * @param {string} errorMsg - Error message to display. If not specified, a * generic WebGL error is displayed. */ function anError(errorMsg) { if (errorMsg === undefined) errorMsg = config.strings.genericWebGLError; infoDisplay.errorMsg.innerHTML = '<p>' + errorMsg + '</p>'; controls.load.style.display = 'none'; infoDisplay.load.box.style.display = 'none'; infoDisplay.errorMsg.style.display = 'table'; error = true; loaded = undefined; renderContainer.style.display = 'none'; fireEvent('error', errorMsg); } /** * Hides error message display. * @private */ function clearError() { if (error) { infoDisplay.load.box.style.display = 'none'; infoDisplay.errorMsg.style.display = 'none'; error = false; renderContainer.style.display = 'block'; fireEvent('errorcleared'); } } /** * Displays about message. * @private * @param {MouseEvent} event - Right click location */ function aboutMessage(event) { var pos = mousePosition(event); aboutMsg.style.left = pos.x + 'px'; aboutMsg.style.top = pos.y + 'px'; clearTimeout(aboutMessage.t1); clearTimeout(aboutMessage.t2); aboutMsg.style.display = 'block'; aboutMsg.style.opacity = 1; aboutMessage.t1 = setTimeout(function() {aboutMsg.style.opacity = 0;}, 2000); aboutMessage.t2 = setTimeout(function() {aboutMsg.style.display = 'none';}, 2500); event.preventDefault(); } /** * Calculate mouse position relative to top left of viewer container. * @private * @param {MouseEvent} event - Mouse event to use in calculation * @returns {Object} Calculated X and Y coordinates */ function mousePosition(event) { var bounds = container.getBoundingClientRect(); var pos = {}; // pageX / pageY needed for iOS pos.x = (event.clientX || event.pageX) - bounds.left; pos.y = (event.clientY || event.pageY) - bounds.top; return pos; } /** * Event handler for mouse clicks. Initializes panning. Prints center and click * location coordinates when hot spot debugging is enabled. * @private * @param {MouseEvent} event - Document mouse down event. */ function onDocumentMouseDown(event) { // Override default action event.preventDefault(); // But not all of it container.focus(); // Only do something if the panorama is loaded if (!loaded || !config.draggable) { return; } // Calculate mouse position relative to top left of viewer container var pos = mousePosition(event); // Log pitch / yaw of mouse click when debugging / placing hot spots if (config.hotSpotDebug) { var coords = mouseEventToCoords(event); console.log('Pitch: ' + coords[0] + ', Yaw: ' + coords[1] + ', Center Pitch: ' + config.pitch + ', Center Yaw: ' + config.yaw + ', HFOV: ' + config.hfov); } // Turn off auto-rotation if enabled stopAnimation(); stopOrientation(); config.roll = 0; speed.hfov = 0; isUserInteracting = true; latestInteraction = Date.now(); onPointerDownPointerX = pos.x; onPointerDownPointerY = pos.y; onPointerDownYaw = config.yaw; onPointerDownPitch = config.pitch; uiContainer.classList.add('pnlm-grabbing'); uiContainer.classList.remove('pnlm-grab'); fireEvent('mousedown', event); animateInit(); } /** * Event handler for double clicks. Zooms in at clicked location * @private * @param {MouseEvent} event - Document mouse down event. */ function onDocumentDoubleClick(event) { if (config.minHfov === config.hfov) { _this.setHfov(origHfov, 1000); } else { var coords = mouseEventToCoords(event); _this.lookAt(coords[0], coords[1], config.minHfov, 1000); } } /** * Calculate panorama pitch and yaw from location of mouse event. * @private * @param {MouseEvent} event - Document mouse down event. * @returns {number[]} [pitch, yaw] */ function mouseEventToCoords(event) { var pos = mousePosition(event); var canvas = renderer.getCanvas(); var canvasWidth = canvas.clientWidth, canvasHeight = canvas.clientHeight; var x = pos.x / canvasWidth * 2 - 1; var y = (1 - pos.y / canvasHeight * 2) * canvasHeight / canvasWidth; var focal = 1 / Math.tan(config.hfov * Math.PI / 360); var s = Math.sin(config.pitch * Math.PI / 180); var c = Math.cos(config.pitch * Math.PI / 180); var a = focal * c - y * s; var root = Math.sqrt(x*x + a*a); var pitch = Math.atan((y * c + focal * s) / root) * 180 / Math.PI; var yaw = Math.atan2(x / root, a / root) * 180 / Math.PI + config.yaw; if (yaw < -180) yaw += 360; if (yaw > 180) yaw -= 360; return [pitch, yaw]; } /** * Event handler for mouse moves. Pans center of view. * @private * @param {MouseEvent} event - Document mouse move event. */ function onDocumentMouseMove(event) { if (isUserInteracting && loaded) { latestInteraction = Date.now(); var canvas = renderer.getCanvas(); var canvasWidth = canvas.clientWidth, canvasHeight = canvas.clientHeight; var pos = mousePosition(event); //TODO: This still isn't quite right var yaw = ((Math.atan(onPointerDownPointerX / canvasWidth * 2 - 1) - Math.atan(pos.x / canvasWidth * 2 - 1)) * 180 / Math.PI * config.hfov / 90) + onPointerDownYaw; speed.yaw = (yaw - config.yaw) % 360 * 0.2; config.yaw = yaw; var vfov = 2 * Math.atan(Math.tan(config.hfov/360*Math.PI) * canvasHeight / canvasWidth) * 180 / Math.PI; var pitch = ((Math.atan(pos.y / canvasHeight * 2 - 1) - Math.atan(onPointerDownPointerY / canvasHeight * 2 - 1)) * 180 / Math.PI * vfov / 90) + onPointerDownPitch; speed.pitch = (pitch - config.pitch) * 0.2; config.pitch = pitch; } } /** * Event handler for mouse up events. Stops panning. * @private */ function onDocumentMouseUp(event) { if (!isUserInteracting) { return; } isUserInteracting = false; if (Date.now() - latestInteraction > 15) { // Prevents jump when user rapidly moves mouse, stops, and then // releases the mouse button speed.pitch = speed.yaw = 0; } uiContainer.classList.add('pnlm-grab'); uiContainer.classList.remove('pnlm-grabbing'); latestInteraction = Date.now(); fireEvent('mouseup', event); } /** * Event handler for touches. Initializes panning if one touch or zooming if * two touches. * @private * @param {TouchEvent} event - Document touch start event. */ function onDocumentTouchStart(event) { // Only do something if the panorama is loaded if (!loaded || !config.draggable) { return; } // Turn off auto-rotation if enabled stopAnimation(); stopOrientation(); config.roll = 0; speed.hfov = 0; // Calculate touch position relative to top left of viewer container var pos0 = mousePosition(event.targetTouches[0]); onPointerDownPointerX = pos0.x; onPointerDownPointerY = pos0.y; if (event.targetTouches.length == 2) { // Down pointer is the center of the two fingers var pos1 = mousePosition(event.targetTouches[1]); onPointerDownPointerX += (pos1.x - pos0.x) * 0.5; onPointerDownPointerY += (pos1.y - pos0.y) * 0.5; onPointerDownPointerDist = Math.sqrt((pos0.x - pos1.x) * (pos0.x - pos1.x) + (pos0.y - pos1.y) * (pos0.y - pos1.y)); } isUserInteracting = true; latestInteraction = Date.now(); onPointerDownYaw = config.yaw; onPointerDownPitch = config.pitch; fireEvent('touchstart', event); animateInit(); } /** * Event handler for touch movements. Pans center of view if one touch or * adjusts zoom if two touches. * @private * @param {TouchEvent} event - Document touch move event. */ function onDocumentTouchMove(event) { if (!config.draggable) { return; } // Override default action event.preventDefault(); if (loaded) { latestInteraction = Date.now(); } if (isUserInteracting && loaded) { var pos0 = mousePosition(event.targetTouches[0]); var clientX = pos0.x; var clientY = pos0.y; if (event.targetTouches.length == 2 && onPointerDownPointerDist != -1) { var pos1 = mousePosition(event.targetTouches[1]); clientX += (pos1.x - pos0.x) * 0.5; clientY += (pos1.y - pos0.y) * 0.5; var clientDist = Math.sqrt((pos0.x - pos1.x) * (pos0.x - pos1.x) + (pos0.y - pos1.y) * (pos0.y - pos1.y)); setHfov(config.hfov + (onPointerDownPointerDist - clientDist) * 0.1); onPointerDownPointerDist = clientDist; } // The smaller the config.hfov value (the more zoomed-in the user is), the faster // yaw/pitch are perceived to change on one-finger touchmove (panning) events and vice versa. // To improve usability at both small and large zoom levels (config.hfov values) // we introduce a dynamic pan speed coefficient. // // Currently this seems to *roughly* keep initial drag/pan start position close to // the user's finger while panning regardless of zoom level / config.hfov value. var touchmovePanSpeedCoeff = (config.hfov / 360) * config.touchPanSpeedCoeffFactor; var yaw = (onPointerDownPointerX - clientX) * touchmovePanSpeedCoeff + onPointerDownYaw; speed.yaw = (yaw - config.yaw) % 360 * 0.2; config.yaw = yaw; var pitch = (clientY - onPointerDownPointerY) * touchmovePanSpeedCoeff + onPointerDownPitch; speed.pitch = (pitch - config.pitch) * 0.2; config.pitch = pitch; } } /** * Event handler for end of touches. Stops panning and/or zooming. * @private */ function onDocumentTouchEnd() { isUserInteracting = false; if (Date.now() - latestInteraction > 150) { speed.pitch = speed.yaw = 0; } onPointerDownPointerDist = -1; latestInteraction = Date.now(); fireEvent('touchend', event); } var pointerIDs = [], pointerCoordinates = []; /** * Event handler for touch starts in IE / Edge. * @private * @param {PointerEvent} event - Document pointer down event. */ function onDocumentPointerDown(event) { if (event.pointerType == 'touch') { // Only do something if the panorama is loaded if (!loaded || !config.draggable) return; pointerIDs.push(event.pointerId); pointerCoordinates.push({clientX: event.clientX, clientY: event.clientY}); event.targetTouches = pointerCoordinates; onDocumentTouchStart(event); event.preventDefault(); } } /** * Event handler for touch moves in IE / Edge. * @private * @param {PointerEvent} event - Document pointer move event. */ function onDocumentPointerMove(event) { if (event.pointerType == 'touch') { if (!config.draggable) return; for (var i = 0; i < pointerIDs.length; i++) { if (event.pointerId == pointerIDs[i]) { pointerCoordinates[i].clientX = event.clientX; pointerCoordinates[i].clientY = event.clientY; event.targetTouches = pointerCoordinates; onDocumentTouchMove(event); event.preventDefault(); return; } } } } /** * Event handler for touch ends in IE / Edge. * @private * @param {PointerEvent} event - Document pointer up event. */ function onDocumentPointerUp(event) { if (event.pointerType == 'touch') { var defined = false; for (var i = 0; i < pointerIDs.length; i++) { if (event.pointerId == pointerIDs[i]) pointerIDs[i] = undefined; if (pointerIDs[i]) defined = true; } if (!defined) { pointerIDs = []; pointerCoordinates = []; onDocumentTouchEnd(); } event.preventDefault(); } } /** * Event handler for mouse wheel. Changes zoom. * @private * @param {WheelEvent} event - Document mouse wheel event. */ function onDocumentMouseWheel(event) { // Only do something if the panorama is loaded and mouse wheel zoom is enabled if (!loaded || (config.mouseZoom == 'fullscreenonly' && !fullscreenActive)) { return; } event.preventDefault(); // Turn off auto-rotation if enabled stopAnimation(); latestInteraction = Date.now(); if (event.wheelDeltaY) { // WebKit setHfov(config.hfov - event.wheelDeltaY * 0.05); speed.hfov = event.wheelDelta < 0 ? 1 : -1; } else if (event.wheelDelta) { // Opera / Explorer 9 setHfov(config.hfov - event.wheelDelta * 0.05); speed.hfov = event.wheelDelta < 0 ? 1 : -1; } else if (event.detail) { // Firefox setHfov(config.hfov + event.detail * 1.5); speed.hfov = event.detail > 0 ? 1 : -1; } animateInit(); } /** * Event handler for key presses. Updates list of currently pressed keys. * @private * @param {KeyboardEvent} event - Document key press event. */ function onDocumentKeyPress(event) { // Turn off auto-rotation if enabled stopAnimation(); latestInteraction = Date.now(); stopOrientation(); config.roll = 0; // Record key pressed var keynumber = event.which || event.keycode; // Override default action for keys that are used if (config.capturedKeyNumbers.indexOf(keynumber) < 0) return; event.preventDefault(); // If escape key is pressed if (keynumber == 27) { // If in fullscreen mode if (fullscreenActive) { toggleFullscreen(); } } else { // Change key changeKey(keynumber, true); } } /** * Clears list of currently pressed keys. * @private */ function clearKeys() { for (var i = 0; i < 10; i++) { keysDown[i] = false; } } /** * Event handler for key releases. Updates list of currently pressed keys. * @private * @param {KeyboardEvent} event - Document key up event. */ function onDocumentKeyUp(event) { // Record key pressed var keynumber = event.which || event.keycode; // Override default action for keys that are used if (config.capturedKeyNumbers.indexOf(keynumber) < 0) return; event.preventDefault(); // Change key changeKey(keynumber, false); } /** * Updates list of currently pressed keys. * @private * @param {number} keynumber - Key number. * @param {boolean} value - Whether or not key is pressed. */ function changeKey(keynumber, value) { var keyChanged = false; switch(keynumber) { // If minus key is released case 109: case 189: case 17: case 173: if (keysDown[0] != value) { keyChanged = true; } keysDown[0] = value; break; // If plus key is released case 107: case 187: case 16: case 61: if (keysDown[1] != value) { keyChanged = true; } keysDown[1] = value; break; // If up arrow is released case 38: if (keysDown[2] != value) { keyChanged = true; } keysDown[2] = value; break; // If "w" is released case 87: if (keysDown[6] != value) { keyChanged = true; } keysDown[6] = value; break; // If down arrow is released case 40: if (keysDown[3] != value) { keyChanged = true; } keysDown[3] = value; break; // If "s" is released case 83: if (keysDown[7] != value) { keyChanged = true; } keysDown[7] = value; break; // If left arrow is released case 37: if (keysDown[4] != value) { keyChanged = true; } keysDown[4] = value; break; // If "a" is released case 65: if (keysDown[8] != value) { keyChanged = true; } keysDown[8] = value; break; // If right arrow is released case 39: if (keysDown[5] != value) { keyChanged = true; } keysDown[5] = value; break; // If "d" is released case 68: if (keysDown[9] != value) { keyChanged = true; } keysDown[9] = value; } if (keyChanged && value) { if (typeof performance !== 'undefined' && performance.now()) { prevTime = performance.now(); } else { prevTime = Date.now(); } animateInit(); } } /** * Pans and/or zooms panorama based on currently pressed keys. Also handles * panorama "inertia" and auto rotation. * @private */ function keyRepeat() { // Only do something if the panorama is loaded if (!loaded) { return; } var isKeyDown = false; var prevPitch = config.pitch; var prevYaw = config.yaw; var prevZoom = config.hfov; var newTime; if (typeof performance !== 'undefined' && performance.now()) { newTime = performance.now(); } else { newTime = Date.now(); } if (prevTime === undefined) { prevTime = newTime; } var diff = (newTime - prevTime) * config.hfov / 1700; diff = Math.min(diff, 1.0); // If minus key is down if (keysDown[0] && config.keyboardZoom === true) { setHfov(config.hfov + (speed.hfov * 0.8 + 0.5) * diff); isKeyDown = true; } // If plus key is down if (keysDown[1] && config.keyboardZoom === true) { setHfov(config.hfov + (speed.hfov * 0.8 - 0.2) * diff); isKeyDown = true; } // If up arrow or "w" is down if (keysDown[2] || keysDown[6]) { // Pan up config.pitch += (speed.pitch * 0.8 + 0.2) * diff; isKeyDown = true; } // If down arrow or "s" is down if (keysDown[3] || keysDown[7]) { // Pan down config.pitch += (speed.pitch * 0.8 - 0.2) * diff; isKeyDown = true; } // If left arrow or "a" is down if (keysDown[4] || keysDown[8]) { // Pan left config.yaw += (speed.yaw * 0.8 - 0.2) * diff; isKeyDown = true; } // If right arrow or "d" is down if (keysDown[5] || keysDown[9]) { // Pan right config.yaw += (speed.yaw * 0.8 + 0.2) * diff; isKeyDown = true; } if (isKeyDown) latestInteraction = Date.now(); // If auto-rotate if (config.autoRotate) { // Pan if (newTime - prevTime > 0.001) { var timeDiff = (newTime - prevTime) / 1000; var yawDiff = (speed.yaw / timeDiff * diff - config.autoRotate * 0.2) * timeDiff; yawDiff = (-config.autoRotate > 0 ? 1 : -1) * Math.min(Math.abs(config.autoRotate * timeDiff), Math.abs(yawDiff)); config.yaw += yawDiff; } // Deal with stopping auto rotation after a set delay if (config.autoRotateStopDelay) { config.autoRotateStopDelay -= newTime - prevTime; if (config.autoRotateStopDelay <= 0) { config.autoRotateStopDelay = false; autoRotateSpeed = config.autoRotate; config.autoRotate = 0; } } } // Animated moves if (animatedMove.pitch) { animateMove('pitch'); prevPitch = config.pitch; } if (animatedMove.yaw) { animateMove('yaw'); prevYaw = config.yaw; } if (animatedMove.hfov) { animateMove('hfov'); prevZoom = config.hfov; } // "Inertia" if (diff > 0 && !config.autoRotate) { // "Friction" var slowDownFactor = 1 - config.friction; // Yaw if (!keysDown[4] && !keysDown[5] && !keysDown[8] && !keysDown[9] && !animatedMove.yaw) { config.yaw += speed.yaw * diff * slowDownFactor; } // Pitch if (!keysDown[2] && !keysDown[3] && !keysDown[6] && !keysDown[7] && !animatedMove.pitch) { config.pitch += speed.pitch * diff * slowDownFactor; } // Zoom if (!keysDown[0] && !keysDown[1] && !animatedMove.hfov) { setHfov(config.hfov + speed.hfov * diff * slowDownFactor); } } prevTime = newTime; if (diff > 0) { speed.yaw = speed.yaw * 0.8 + (config.yaw - prevYaw) / diff * 0.2; speed.pitch = speed.pitch * 0.8 + (config.pitch - prevPitch) / diff * 0.2; speed.hfov = speed.hfov * 0.8 + (config.hfov - prevZoom) / diff * 0.2; // Limit speed var maxSpeed = config.autoRotate ? Math.abs(config.autoRotate) : 5; speed.yaw = Math.min(maxSpeed, Math.max(speed.yaw, -maxSpeed)); speed.pitch = Math.min(maxSpeed, Math.max(speed.pitch, -maxSpeed)); speed.hfov = Math.min(maxSpeed, Math.max(speed.hfov, -maxSpeed)); } // Stop movement if opposite controls are pressed if (keysDown[0] && keysDown[1]) { speed.hfov = 0; } if ((keysDown[2] || keysDown[6]) && (keysDown[3] || keysDown[7])) { speed.pitch = 0; } if ((keysDown[4] || keysDown[8]) && (keysDown[5] || keysDown[9])) { speed.yaw = 0; } } /** * Animates moves. * @param {string} axis - Axis to animate * @private */ function animateMove(axis) { var t = animatedMove[axis]; var normTime = Math.min(1, Math.max((Date.now() - t.startTime) / 1000 / (t.duration / 1000), 0)); var result = t.startPosition + config.animationTimingFunction(normTime) * (t.endPosition - t.startPosition); if ((t.endPosition > t.startPosition && result >= t.endPosition) || (t.endPosition < t.startPosition && result <= t.endPosition) || t.endPosition === t.startPosition) { result = t.endPosition; speed[axis] = 0; delete animatedMove[axis]; } config[axis] = result; } /** * @param {number} t - Normalized time in animation * @return {number} Position in animation * @private */ function timingFunction(t) { // easeInOutQuad from https://gist.github.com/gre/1650294 return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; } /** * Event handler for document resizes. Updates viewer size and rerenders view. * @private */ function onDocumentResize() { // Resize panorama renderer (moved to onFullScreenChange) //renderer.resize(); //animateInit(); // Kludge to deal with WebKit regression: https://bugs.webkit.org/show_bug.cgi?id=93525 onFullScreenChange('resize'); } /** * Initializes animation. * @private */ function animateInit() { if (animating) { return; } animating = true; animate(); } /** * Animates view, using requestAnimationFrame to trigger rendering. * @private */ function animate() { if (destroyed) { return; } render(); if (autoRotateStart) clearTimeout(autoRotateStart); if (isUserInteracting || orientation === true) { requestAnimationFrame(animate); } else if (keysDown[0] || keysDown[1] || keysDown[2] || keysDown[3] || keysDown[4] || keysDown[5] || keysDown[6] || keysDown[7] || keysDown[8] || keysDown[9] || config.autoRotate || animatedMove.pitch || animatedMove.yaw || animatedMove.hfov || Math.abs(speed.yaw) > 0.01 || Math.abs(speed.pitch) > 0.01 || Math.abs(speed.hfov) > 0.01) { keyRepeat(); if (config.autoRotateInactivityDelay >= 0 && autoRotateSpeed && Date.now() - latestInteraction > config.autoRotateInactivityDelay && !config.autoRotate) { config.autoRotate = autoRotateSpeed; _this.lookAt(origPitch, undefined, origHfov, 3000); } requestAnimationFrame(animate); } else if (renderer && (renderer.isLoading() || (config.dynamic === true && update))) { requestAnimationFrame(animate); } else { fireEvent('animatefinished', {pitch: _this.getPitch(), yaw: _this.getYaw(), hfov: _this.getHfov()}); animating = false; prevTime = undefined; var autoRotateStartTime = config.autoRotateInactivityDelay - (Date.now() - latestInteraction); if (autoRotateStartTime > 0) { autoRotateStart = setTimeout(function() { config.autoRotate = autoRotateSpeed; _this.lookAt(origPitch, undefined, origHfov, 3000); animateInit(); }, autoRotateStartTime); } else if (config.autoRotateInactivityDelay >= 0 && autoRotateSpeed) { config.autoRotate = autoRotateSpeed; _this.lookAt(origPitch, undefined, origHfov, 3000); animateInit(); } } } /** * Renders panorama view. * @private */ function render() { var tmpyaw; if (loaded) { var canvas = renderer.getCanvas(); if (config.autoRotate !== false) { // When auto-rotating this check needs to happen first (see issue #764) if (config.yaw > 360) { config.yaw -= 360; } else if (config.yaw < -360) { config.yaw += 360; } } // Keep a tmp value of yaw for autoRotate comparison later tmpyaw = config.yaw; // Optionally avoid showing background (empty space) on left or right by adapting min/max yaw var hoffcut = 0, voffcut = 0; if (config.avoidShowingBackground) { var hfov2 = config.hfov / 2,