PageSourceSearch

https://www.3h-automobile.de/assets/js/pannellum/pannellum.js

js 3h-automobile.de collected 2026-10-08 22:52:48 UTC 105,688 bytes, 3,156 lines download raw bytes

1/*
2 * Pannellum - An HTML5 based Panorama Viewer
3 * Copyright (c) 2011-2019 Matthew Petroff
4 * 
5 * Permission is hereby granted, free of charge, to any person obtaining a copy
6 * of this software and associated documentation files (the "Software"), to deal
7 * in the Software without restriction, including without limitation the rights
8 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 * copies of the Software, and to permit persons to whom the Software is
10 * furnished to do so, subject to the following conditions:
11 * 
12 * The above copyright notice and this permission notice shall be included in
13 * all copies or substantial portions of the Software.
14 * 
15 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 * THE SOFTWARE.
22 */
23
24window.pannellum = (function(window, document, undefined) {
25
26'use strict';
27
28/**
29 * Creates a new panorama viewer.
30 * @constructor
31 * @param {HTMLElement|string} container - The container (div) element for the
32 *      viewer, or its ID.
33 * @param {Object} initialConfig - Inital configuration for viewer.
34 */
35function Viewer(container, initialConfig) {
36
37var _this = this;
38
39// Declare variables
40var config,
41    renderer,
42    preview,
43    isUserInteracting = false,
44    latestInteraction = Date.now(),
45    onPointerDownPointerX = 0,
46    onPointerDownPointerY = 0,
47    onPointerDownPointerDist = -1,
48    onPointerDownYaw = 0,
49    onPointerDownPitch = 0,
50    keysDown = new Array(10),
51    fullscreenActive = false,
52    loaded,
53    error = false,
54    isTimedOut = false,
55    listenersAdded = false,
56    panoImage,
57    prevTime,
58    speed = {'yaw': 0, 'pitch': 0, 'hfov': 0},
59    animating = false,
60    orientation = false,
61    orientationYawOffset = 0,
62    autoRotateStart,
63    autoRotateSpeed = 0,
64    origHfov,
65    origPitch,
66    animatedMove = {},
67    externalEventListeners = {},
68    specifiedPhotoSphereExcludes = [],
69    update = false, // Should we update when still to render dynamic content
70    eps = 1e-6,
71    hotspotsCreated = false,
72    destroyed = false;
73
74var defaultConfig = {
75    hfov: 100,
76    minHfov: 50,
77    multiResMinHfov: false,
78    maxHfov: 120,
79    pitch: 0,
80    minPitch: undefined,
81    maxPitch: undefined,
82    yaw: 0,
83    minYaw: -180,
84    maxYaw: 180,
85    roll: 0,
86    haov: 360,
87    vaov: 180,
88    vOffset: 0,
89    autoRotate: false,
90    autoRotateInactivityDelay: -1,
91    autoRotateStopDelay: undefined,
92    type: 'equirectangular',
93    northOffset: 0,
94    showFullscreenCtrl: true,
95    dynamic: false,
96    dynamicUpdate: false,
97    doubleClickZoom: true,
98    keyboardZoom: true,
99    mouseZoom: true,
100    showZoomCtrl: true,
101    autoLoad: false,
102    showControls: true,
103    orientationOnByDefault: false,
104    hotSpotDebug: false,
105    backgroundColor: [0, 0, 0],
106    avoidShowingBackground: false,
107    animationTimingFunction: timingFunction,
108    draggable: true,
109    disableKeyboardCtrl: false,
110    crossOrigin: 'anonymous',
111    touchPanSpeedCoeffFactor: 1,
112    capturedKeyNumbers: [16, 17, 27, 37, 38, 39, 40, 61, 65, 68, 83, 87, 107, 109, 173, 187, 189],
113    friction: 0.15
114};
115
116// Translatable / configurable strings
117// Some strings contain '%s', which is a placeholder for inserted values
118// When setting strings in external configuration, `\n` should be used instead of `<br>` to insert line breaks
119defaultConfig.strings = {
120    // Labels
121    loadButtonLabel: 'Click to<br>Load<br>Panorama',
122    loadingLabel: 'Loading...',
123    bylineLabel: 'by %s',    // One substitution: author
124
125    // Errors
126    noPanoramaError: 'No panorama image was specified.',
127    fileAccessError: 'The file %s could not be accessed.',  // One substitution: file URL
128    malformedURLError: 'There is something wrong with the panorama URL.',
129    iOS8WebGLError: "Due to iOS 8's broken WebGL implementation, only " +
130                    "progressive encoded JPEGs work for your device (this " +
131                    "panorama uses standard encoding).",
132    genericWebGLError: 'Your browser does not have the necessary WebGL support to display this panorama.',
133    textureSizeError: 'This panorama is too big for your device! It\'s ' +
134                '%spx wide, but your device only supports images up to ' +
135                '%spx wide. Try another device.' +
136                ' (If you\'re the author, try scaling down the image.)',    // Two substitutions: image width, max image width
137    unknownError: 'Unknown error. Check developer console.',
138};
139
140// Initialize container
141container = typeof container === 'string' ? document.getElementById(container) : container;
142container.classList.add('pnlm-container');
143container.tabIndex = 0;
144
145// Create container for ui
146var uiContainer = document.createElement('div');
147uiContainer.className = 'pnlm-ui';
148container.appendChild(uiContainer);
149
150// Create container for renderer
151var renderContainer = document.createElement('div');
152renderContainer.className = 'pnlm-render-container';
153container.appendChild(renderContainer);
154var dragFix = document.createElement('div');
155dragFix.className = 'pnlm-dragfix';
156uiContainer.appendChild(dragFix);
157
158// Display about information on right click
159var aboutMsg = document.createElement('span');
160aboutMsg.className = 'pnlm-about-msg';
161aboutMsg.innerHTML = '<a href="https://pannellum.org/" target="_blank">Pannellum</a>';
162uiContainer.appendChild(aboutMsg);
163dragFix.addEventListener('contextmenu', aboutMessage);
164
165// Create info display
166var infoDisplay = {};
167
168// Hot spot debug indicator
169var hotSpotDebugIndicator = document.createElement('div');
170hotSpotDebugIndicator.className = 'pnlm-sprite pnlm-hot-spot-debug-indicator';
171uiContainer.appendChild(hotSpotDebugIndicator);
172
173// Panorama info
174infoDisplay.container = document.createElement('div');
175infoDisplay.container.className = 'pnlm-panorama-info';
176infoDisplay.title = document.createElement('div');
177infoDisplay.title.className = 'pnlm-title-box';
178infoDisplay.container.appendChild(infoDisplay.title);
179infoDisplay.author = document.createElement('div');
180infoDisplay.author.className = 'pnlm-author-box';
181infoDisplay.container.appendChild(infoDisplay.author);
182uiContainer.appendChild(infoDisplay.container);
183
184// Load box
185infoDisplay.load = {};
186infoDisplay.load.box = document.createElement('div');
187infoDisplay.load.box.className = 'pnlm-load-box';
188infoDisplay.load.boxp = document.createElement('p');
189infoDisplay.load.box.appendChild(infoDisplay.load.boxp);
190infoDisplay.load.lbox = document.createElement('div');
191infoDisplay.load.lbox.className = 'pnlm-lbox';
192infoDisplay.load.lbox.innerHTML = '<div class="pnlm-loading"></div>';
193infoDisplay.load.box.appendChild(infoDisplay.load.lbox);
194infoDisplay.load.lbar = document.createElement('div');
195infoDisplay.load.lbar.className = 'pnlm-lbar';
196infoDisplay.load.lbarFill = document.createElement('div');
197infoDisplay.load.lbarFill.className = 'pnlm-lbar-fill';
198infoDisplay.load.lbar.appendChild(infoDisplay.load.lbarFill);
199infoDisplay.load.box.appendChild(infoDisplay.load.lbar);
200infoDisplay.load.msg = document.createElement('p');
201infoDisplay.load.msg.className = 'pnlm-lmsg';
202infoDisplay.load.box.appendChild(infoDisplay.load.msg);
203uiContainer.appendChild(infoDisplay.load.box);
204
205// Error message
206infoDisplay.errorMsg = document.createElement('div');
207infoDisplay.errorMsg.className = 'pnlm-error-msg pnlm-info-box';
208uiContainer.appendChild(infoDisplay.errorMsg);
209
210// Create controls
211var controls = {};
212controls.container = document.createElement('div');
213controls.container.className = 'pnlm-controls-container';
214uiContainer.appendChild(controls.container);
215
216// Load button
217controls.load = document.createElement('div');
218controls.load.className = 'pnlm-load-button';
219controls.load.addEventListener('click', function() {
220    processOptions();
221    load();
222});
223uiContainer.appendChild(controls.load);
224
225// Zoom controls
226controls.zoom = document.createElement('div');
227controls.zoom.className = 'pnlm-zoom-controls pnlm-controls';
228controls.zoomIn = document.createElement('div');
229controls.zoomIn.className = 'pnlm-zoom-in pnlm-sprite pnlm-control';
230controls.zoomIn.addEventListener('click', zoomIn);
231controls.zoom.appendChild(controls.zoomIn);
232controls.zoomOut = document.createElement('div');
233controls.zoomOut.className = 'pnlm-zoom-out pnlm-sprite pnlm-control';
234controls.zoomOut.addEventListener('click', zoomOut);
235controls.zoom.appendChild(controls.zoomOut);
236controls.container.appendChild(controls.zoom);
237
238// Fullscreen toggle
239controls.fullscreen = document.createElement('div');
240controls.fullscreen.addEventListener('click', toggleFullscreen);
241controls.fullscreen.className = 'pnlm-fullscreen-toggle-button pnlm-sprite pnlm-fullscreen-toggle-button-inactive pnlm-controls pnlm-control';
242if (document.fullscreenEnabled || document.mozFullScreenEnabled || document.webkitFullscreenEnabled || document.msFullscreenEnabled)
243    controls.container.appendChild(controls.fullscreen);
244
245// Device orientation toggle
246controls.orientation = document.createElement('div');
247controls.orientation.addEventListener('click', function(e) {
248    if (orientation)
249        stopOrientation();
250    else
251        startOrientation();
252});
253controls.orientation.addEventListener('mousedown', function(e) {e.stopPropagation();});
254controls.orientation.addEventListener('touchstart', function(e) {e.stopPropagation();});
255controls.orientation.addEventListener('pointerdown', function(e) {e.stopPropagation();});
256controls.orientation.className = 'pnlm-orientation-button pnlm-orientation-button-inactive pnlm-sprite pnlm-controls pnlm-control';
257var orientationSupport = false;
258if (window.DeviceOrientationEvent && location.protocol == 'https:' &&
259    navigator.userAgent.toLowerCase().indexOf('mobi') >= 0) {
260    // This user agent check is here because there's no way to check if a
261    // device has an inertia measurement unit. We used to be able to check if a
262    // DeviceOrientationEvent had non-null values, but with iOS 13 requiring a
263    // permission prompt to access such events, this is no longer possible.
264    controls.container.appendChild(controls.orientation);
265    orientationSupport = true;
266}
267
268// Compass
269var compass = document.createElement('div');
270compass.className = 'pnlm-compass pnlm-controls pnlm-control';
271uiContainer.appendChild(compass);
272
273// Load and process configuration
274if (initialConfig.firstScene) {
275    // Activate first scene if specified in URL
276    mergeConfig(initialConfig.firstScene);
277} else if (initialConfig.default && initialConfig.default.firstScene) {
278    // Activate first scene if specified in file
279    mergeConfig(initialConfig.default.firstScene);
280} else {
281    mergeConfig(null);
282}
283processOptions(true);
284
285/**
286 * Initializes viewer.
287 * @private
288 */
289function init() {
290    // Display an error for IE 9 as it doesn't work but also doesn't otherwise
291    // show an error (older versions don't work at all)
292    // Based on: http://stackoverflow.com/a/10965203
293    var div = document.createElement("div");
294    div.innerHTML = "<!--[if lte IE 9]><i></i><![endif]-->";
295    if (div.getElementsByTagName("i").length == 1) {
296        anError();
297        return;
298    }
299
300    origHfov = config.hfov;
301    origPitch = config.pitch;
302
303    var i, p;
304    
305    if (config.type == 'cubemap') {
306        panoImage = [];
307        for (i = 0; i < 6; i++) {
308            panoImage.push(new Image());
309            panoImage[i].crossOrigin = config.crossOrigin;
310        }
311        infoDisplay.load.lbox.style.display = 'block';
312        infoDisplay.load.lbar.style.display = 'none';
313    } else if (config.type == 'multires') {
314        var c = JSON.parse(JSON.stringify(config.multiRes));    // Deep copy
315        // Avoid "undefined" in path, check (optional) multiRes.basePath, too
316        // Use only multiRes.basePath if it's an absolute URL
317        if (config.basePath && config.multiRes.basePath &&
318            !(/^(?:[a-z]+:)?\/\//i.test(config.multiRes.basePath))) {
319            c.basePath = config.basePath + config.multiRes.basePath;
320        } else if (config.multiRes.basePath) {
321            c.basePath = config.multiRes.basePath;
322        } else if(config.basePath) {
323            c.basePath = config.basePath;
324        }
325        panoImage = c;
326    } else {
327        if (config.dynamic === true) {
328            panoImage = config.panorama;
329        } else {
330            if (config.panorama === undefined) {
331                anError(config.strings.noPanoramaError);
332                return;
333            }
334            panoImage = new Image();
335        }
336    }
337
338    // Configure image loading
339    if (config.type == 'cubemap') {
340        // Quick loading counter for synchronous loading
341        var itemsToLoad = 6;
342        
343        var onLoad = function() {
344            itemsToLoad--;
345            if (itemsToLoad === 0) {
346                onImageLoad();
347            }
348        };
349        
350        var onError = function(e) {
351            var a = document.createElement('a');
352            a.href = e.target.src;
353            a.textContent = a.href;
vendor: 4,118 bytes, lines 354-462
354            anError(config.strings.fileAccessError.replace('%s', a.outerHTML));
355        };
356        
357        for (i = 0; i < panoImage.length; i++) {
358            p = config.cubeMap[i];
359            if (p == "null") { // support partial cubemap image with explicitly empty faces
360                console.log('Will use background instead of missing cubemap face ' + i);
361                onLoad();
362            } else {
363                if (config.basePath && !absoluteURL(p)) {
364                    p = config.basePath + p;
365                }
366                panoImage[i].onload = onLoad;
367                panoImage[i].onerror = onError;
368                panoImage[i].src = sanitizeURL(p);
369            }
370        }
371    } else if (config.type == 'multires') {
372        onImageLoad();
373    } else {
374        p = '';
375        if (config.basePath) {
376            p = config.basePath;
377        }
378        
379        if (config.dynamic !== true) {
380            // Still image
381            p = absoluteURL(config.panorama) ? config.panorama : p + config.panorama;
382            
383            panoImage.onload = function() {
384                window.URL.revokeObjectURL(this.src);  // Clean up
385                onImageLoad();
386            };
387            
388            var xhr = new XMLHttpRequest();
389            xhr.onloadend = function() {
390                if (xhr.status != 200) {
391                    // Display error if image can't be loaded
392                    var a = document.createElement('a');
393                    a.href = p;
394                    a.textContent = a.href;
395                    anError(config.strings.fileAccessError.replace('%s', a.outerHTML));
396                }
397                var img = this.response;
398                parseGPanoXMP(img);
399                infoDisplay.load.msg.innerHTML = '';
400            };
401            xhr.onprogress = function(e) {
402                if (e.lengthComputable) {
403                    // Display progress
404                    var percent = e.loaded / e.total * 100;
405                    infoDisplay.load.lbarFill.style.width = percent + '%';
406                    var unit, numerator, denominator;
407                    if (e.total > 1e6) {
408                        unit = 'MB';
409                        numerator = (e.loaded / 1e6).toFixed(2);
410                        denominator = (e.total / 1e6).toFixed(2);
411                    } else if (e.total > 1e3) {
412                        unit = 'kB';
413                        numerator = (e.loaded / 1e3).toFixed(1);
414                        denominator = (e.total / 1e3).toFixed(1);
415                    } else {
416                        unit = 'B';
417                        numerator = e.loaded;
418                        denominator = e.total;
419                    }
420                    infoDisplay.load.msg.innerHTML = numerator + ' / ' + denominator + ' ' + unit;
421                } else {
422                    // Display loading spinner
423                    infoDisplay.load.lbox.style.display = 'block';
424                    infoDisplay.load.lbar.style.display = 'none';
425                }
426            };
427            try {
428                xhr.open('GET', p, true);
429            } catch (e) {
430                // Malformed URL
431                anError(config.strings.malformedURLError);
432            }
433            xhr.responseType = 'blob';
434            xhr.setRequestHeader('Accept', 'image/*,*/*;q=0.9');
435            xhr.withCredentials = config.crossOrigin === 'use-credentials';
436            xhr.send();
437        }
438    }
439    
440    if (config.draggable)
441        uiContainer.classList.add('pnlm-grab');
442    uiContainer.classList.remove('pnlm-grabbing');
443
444    // Properly handle switching to dynamic scenes
445    update = config.dynamicUpdate === true;
446    if (config.dynamic && update) {
447        panoImage = config.panorama;
448        onImageLoad();
449    }
450}
451
452/**
453 * Test if URL is absolute or relative.
454 * @private
455 * @param {string} url - URL to test
456 * @returns {boolean} True if absolute, else false
457 */
458function absoluteURL(url) {
459    // From http://stackoverflow.com/a/19709846
460    return new RegExp('^(?:[a-z]+:)?//', 'i').test(url) || url[0] == '/' || url.slice(0, 5) == 'blob:';
461}
462
463/**
464 * Create renderer and initialize event listeners once image is loaded.
465 * @private
466 */
467function onImageLoad() {
468    if (!renderer)
469        renderer = new libpannellum.renderer(renderContainer);
470
471    // Only add event listeners once
472    if (!listenersAdded) {
473        listenersAdded = true;
474        dragFix.addEventListener('mousedown', onDocumentMouseDown, false);
475        document.addEventListener('mousemove', onDocumentMouseMove, false);
476        document.addEventListener('mouseup', onDocumentMouseUp, false);
477        if (config.mouseZoom) {
478            uiContainer.addEventListener('mousewheel', onDocumentMouseWheel, false);
479            uiContainer.addEventListener('DOMMouseScroll', onDocumentMouseWheel, false);
480        }
481        if (config.doubleClickZoom) {
482            dragFix.addEventListener('dblclick', onDocumentDoubleClick, false);
483        }
484        container.addEventListener('mozfullscreenchange', onFullScreenChange, false);
485        container.addEventListener('webkitfullscreenchange', onFullScreenChange, false);
486        container.addEventListener('msfullscreenchange', onFullScreenChange, false);
487        container.addEventListener('fullscreenchange', onFullScreenChange, false);
488        window.addEventListener('resize', onDocumentResize, false);
489        window.addEventListener('orientationchange', onDocumentResize, false);
490        if (!config.disableKeyboardCtrl) {
491            container.addEventListener('keydown', onDocumentKeyPress, false);
492            container.addEventListener('keyup', onDocumentKeyUp, false);
493            container.addEventListener('blur', clearKeys, false);
494        }
495        document.addEventListener('mouseleave', onDocumentMouseUp, false);
496        if (document.documentElement.style.pointerAction === '' &&
497            document.documentElement.style.touchAction === '') {
498            dragFix.addEventListener('pointerdown', onDocumentPointerDown, false);
499            dragFix.addEventListener('pointermove', onDocumentPointerMove, false);
500            dragFix.addEventListener('pointerup', onDocumentPointerUp, false);
501            dragFix.addEventListener('pointerleave', onDocumentPointerUp, false);
502        } else {
503            dragFix.addEventListener('touchstart', onDocumentTouchStart, false);
504            dragFix.addEventListener('touchmove', onDocumentTouchMove, false);
505            dragFix.addEventListener('touchend', onDocumentTouchEnd, false);
506        }
507
508        // Deal with MS pointer events
509        if (window.navigator.pointerEnabled)
510            container.style.touchAction = 'none';
511    }
512
513    renderInit();
514    setHfov(config.hfov); // possibly adapt hfov after configuration and canvas is complete; prevents empty space on top or bottom by zomming out too much
515    setTimeout(function(){isTimedOut = true;}, 500);
516}
517
518/**
519 * Parses Google Photo Sphere XMP Metadata.
520 * https://developers.google.com/photo-sphere/metadata/
521 * @private
522 * @param {Image} image - Image to read XMP metadata from.
523 */
524function parseGPanoXMP(image) {
525    var reader = new FileReader();
526    reader.addEventListener('loadend', function() {
527        var img = reader.result;
528
529        // This awful browser specific test exists because iOS 8 does not work
530        // with non-progressive encoded JPEGs.
531        if (navigator.userAgent.toLowerCase().match(/(iphone|ipod|ipad).* os 8_/)) {
532            var flagIndex = img.indexOf('\xff\xc2');
533            if (flagIndex < 0 || flagIndex > 65536)
534                anError(config.strings.iOS8WebGLError);
535        }
536
537        var start = img.indexOf('<x:xmpmeta');
538        if (start > -1 && config.ignoreGPanoXMP !== true) {
539            var xmpData = img.substring(start, img.indexOf('</x:xmpmeta>') + 12);
540            
541            // Extract the requested tag from the XMP data
542            var getTag = function(tag) {
543                var result;
544                if (xmpData.indexOf(tag + '="') >= 0) {
545                    result = xmpData.substring(xmpData.indexOf(tag + '="') + tag.length + 2);
546                    result = result.substring(0, result.indexOf('"'));
547                } else if (xmpData.indexOf(tag + '>') >= 0) {
548                    result = xmpData.substring(xmpData.indexOf(tag + '>') + tag.length + 1);
549                    result = result.substring(0, result.indexOf('<'));
550                }
551                if (result !== undefined) {
552                    return Number(result);
553                }
554                return null;
555            };
556            
557            // Relevant XMP data
558            var xmp = {
559                fullWidth: getTag('GPano:FullPanoWidthPixels'),
560                croppedWidth: getTag('GPano:CroppedAreaImageWidthPixels'),
561                fullHeight: getTag('GPano:FullPanoHeightPixels'),
562                croppedHeight: getTag('GPano:CroppedAreaImageHeightPixels'),
563                topPixels: getTag('GPano:CroppedAreaTopPixels'),
564                heading: getTag('GPano:PoseHeadingDegrees'),
565                horizonPitch: getTag('GPano:PosePitchDegrees'),
566                horizonRoll: getTag('GPano:PoseRollDegrees')
567            };
568            
569            if (xmp.fullWidth !== null && xmp.croppedWidth !== null &&
570                xmp.fullHeight !== null && xmp.croppedHeight !== null &&
571                xmp.topPixels !== null) {
572                
573                // Set up viewer using GPano XMP data
574                if (specifiedPhotoSphereExcludes.indexOf('haov') < 0)
575                    config.haov = xmp.croppedWidth / xmp.fullWidth * 360;
576                if (specifiedPhotoSphereExcludes.indexOf('vaov') < 0)
577                    config.vaov = xmp.croppedHeight / xmp.fullHeight * 180;
578                if (specifiedPhotoSphereExcludes.indexOf('vOffset') < 0)
579                    config.vOffset = ((xmp.topPixels + xmp.croppedHeight / 2) / xmp.fullHeight - 0.5) * -180;
580                if (xmp.heading !== null && specifiedPhotoSphereExcludes.indexOf('northOffset') < 0) {
581                    // TODO: make sure this works correctly for partial panoramas
582                    config.northOffset = xmp.heading;
583                    if (config.compass !== false) {
584                        config.compass = true;
585                    }
586                }
587                if (xmp.horizonPitch !== null && xmp.horizonRoll !== null) {
588                    if (specifiedPhotoSphereExcludes.indexOf('horizonPitch') < 0)
589                        config.horizonPitch = xmp.horizonPitch;
590                    if (specifiedPhotoSphereExcludes.indexOf('horizonRoll') < 0)
591                        config.horizonRoll = xmp.horizonRoll;
592                }
593                
594                // TODO: add support for initial view settings
595            }
596        }
597        
598        // Load panorama
599        panoImage.src = window.URL.createObjectURL(image);
600    });
601    if (reader.readAsBinaryString !== undefined)
602        reader.readAsBinaryString(image);
603    else
604        reader.readAsText(image);
605}
606
607/**
608 * Displays an error message.
609 * @private
610 * @param {string} errorMsg - Error message to display. If not specified, a
611 *      generic WebGL error is displayed.
612 */
613function anError(errorMsg) {
614    if (errorMsg === undefined)
615        errorMsg = config.strings.genericWebGLError;
616    infoDisplay.errorMsg.innerHTML = '<p>' + errorMsg + '</p>';
617    controls.load.style.display = 'none';
618    infoDisplay.load.box.style.display = 'none';
619    infoDisplay.errorMsg.style.display = 'table';
620    error = true;
621    loaded = undefined;
622    renderContainer.style.display = 'none';
623    fireEvent('error', errorMsg);
624}
625
626/**
627 * Hides error message display.
628 * @private
629 */
630function clearError() {
631    if (error) {
632        infoDisplay.load.box.style.display = 'none';
633        infoDisplay.errorMsg.style.display = 'none';
634        error = false;
635        renderContainer.style.display = 'block';
636        fireEvent('errorcleared');
637    }
638}
639
640/**
641 * Displays about message.
642 * @private
643 * @param {MouseEvent} event - Right click location
644 */
645function aboutMessage(event) {
646    var pos = mousePosition(event);
647    aboutMsg.style.left = pos.x + 'px';
648    aboutMsg.style.top = pos.y + 'px';
649    clearTimeout(aboutMessage.t1);
650    clearTimeout(aboutMessage.t2);
651    aboutMsg.style.display = 'block';
652    aboutMsg.style.opacity = 1;
653    aboutMessage.t1 = setTimeout(function() {aboutMsg.style.opacity = 0;}, 2000);
654    aboutMessage.t2 = setTimeout(function() {aboutMsg.style.display = 'none';}, 2500);
655    event.preventDefault();
656}
657
658/**
659 * Calculate mouse position relative to top left of viewer container.
660 * @private
661 * @param {MouseEvent} event - Mouse event to use in calculation
662 * @returns {Object} Calculated X and Y coordinates
663 */
664function mousePosition(event) {
665    var bounds = container.getBoundingClientRect();
666    var pos = {};
667    // pageX / pageY needed for iOS
668    pos.x = (event.clientX || event.pageX) - bounds.left;
669    pos.y = (event.clientY || event.pageY) - bounds.top;
670    return pos;
671}
672
673/**
674 * Event handler for mouse clicks. Initializes panning. Prints center and click
675 * location coordinates when hot spot debugging is enabled.
676 * @private
677 * @param {MouseEvent} event - Document mouse down event.
678 */
679function onDocumentMouseDown(event) {
680    // Override default action
681    event.preventDefault();
682    // But not all of it
683    container.focus();
684    
685    // Only do something if the panorama is loaded
686    if (!loaded || !config.draggable) {
687        return;
688    }
689    
690    // Calculate mouse position relative to top left of viewer container
691    var pos = mousePosition(event);
692
693    // Log pitch / yaw of mouse click when debugging / placing hot spots
694    if (config.hotSpotDebug) {
695        var coords = mouseEventToCoords(event);
696        console.log('Pitch: ' + coords[0] + ', Yaw: ' + coords[1] + ', Center Pitch: ' +
697            config.pitch + ', Center Yaw: ' + config.yaw + ', HFOV: ' + config.hfov);
698    }
699    
700    // Turn off auto-rotation if enabled
701    stopAnimation();
702
703    stopOrientation();
704    config.roll = 0;
705
706    speed.hfov = 0;
707
708    isUserInteracting = true;
709    latestInteraction = Date.now();
710    
711    onPointerDownPointerX = pos.x;
712    onPointerDownPointerY = pos.y;
713    
714    onPointerDownYaw = config.yaw;
715    onPointerDownPitch = config.pitch;
716    
717    uiContainer.classList.add('pnlm-grabbing');
718    uiContainer.classList.remove('pnlm-grab');
719    
720    fireEvent('mousedown', event);
721    animateInit();
722}
723
724/**
725 * Event handler for double clicks. Zooms in at clicked location
726 * @private
727 * @param {MouseEvent} event - Document mouse down event.
728 */
729function onDocumentDoubleClick(event) {
730    if (config.minHfov === config.hfov) {
731        _this.setHfov(origHfov, 1000);
732    } else {
733        var coords = mouseEventToCoords(event);
734        _this.lookAt(coords[0], coords[1], config.minHfov, 1000);
735    }
736}
737
738/**
739 * Calculate panorama pitch and yaw from location of mouse event.
740 * @private
741 * @param {MouseEvent} event - Document mouse down event.
742 * @returns {number[]} [pitch, yaw]
743 */
744function mouseEventToCoords(event) {
745    var pos = mousePosition(event);
746    var canvas = renderer.getCanvas();
747    var canvasWidth = canvas.clientWidth,
748        canvasHeight = canvas.clientHeight;
749    var x = pos.x / canvasWidth * 2 - 1;
750    var y = (1 - pos.y / canvasHeight * 2) * canvasHeight / canvasWidth;
751    var focal = 1 / Math.tan(config.hfov * Math.PI / 360);
752    var s = Math.sin(config.pitch * Math.PI / 180);
753    var c = Math.cos(config.pitch * Math.PI / 180);
754    var a = focal * c - y * s;
755    var root = Math.sqrt(x*x + a*a);
756    var pitch = Math.atan((y * c + focal * s) / root) * 180 / Math.PI;
757    var yaw = Math.atan2(x / root, a / root) * 180 / Math.PI + config.yaw;
758    if (yaw < -180)
759        yaw += 360;
760    if (yaw > 180)
761        yaw -= 360;
762    return [pitch, yaw];
763}
764
765/**
766 * Event handler for mouse moves. Pans center of view.
767 * @private
768 * @param {MouseEvent} event - Document mouse move event.
769 */
770function onDocumentMouseMove(event) {
771    if (isUserInteracting && loaded) {
772        latestInteraction = Date.now();
773        var canvas = renderer.getCanvas();
774        var canvasWidth = canvas.clientWidth,
775            canvasHeight = canvas.clientHeight;
776        var pos = mousePosition(event);
777        //TODO: This still isn't quite right
778        var yaw = ((Math.atan(onPointerDownPointerX / canvasWidth * 2 - 1) - Math.atan(pos.x / canvasWidth * 2 - 1)) * 180 / Math.PI * config.hfov / 90) + onPointerDownYaw;
779        speed.yaw = (yaw - config.yaw) % 360 * 0.2;
780        config.yaw = yaw;
781        
782        var vfov = 2 * Math.atan(Math.tan(config.hfov/360*Math.PI) * canvasHeight / canvasWidth) * 180 / Math.PI;
783        
784        var pitch = ((Math.atan(pos.y / canvasHeight * 2 - 1) - Math.atan(onPointerDownPointerY / canvasHeight * 2 - 1)) * 180 / Math.PI * vfov / 90) + onPointerDownPitch;
785        speed.pitch = (pitch - config.pitch) * 0.2;
786        config.pitch = pitch;
787    }
788}
789
790/**
791 * Event handler for mouse up events. Stops panning.
792 * @private
793 */
794function onDocumentMouseUp(event) {
795    if (!isUserInteracting) {
796        return;
797    }
798    isUserInteracting = false;
799    if (Date.now() - latestInteraction > 15) {
800        // Prevents jump when user rapidly moves mouse, stops, and then
801        // releases the mouse button
802        speed.pitch = speed.yaw = 0;
803    }
804    uiContainer.classList.add('pnlm-grab');
805    uiContainer.classList.remove('pnlm-grabbing');
806    latestInteraction = Date.now();
807
808    fireEvent('mouseup', event);
809}
810
811/**
812 * Event handler for touches. Initializes panning if one touch or zooming if
813 * two touches.
814 * @private
815 * @param {TouchEvent} event - Document touch start event.
816 */
817function onDocumentTouchStart(event) {
818    // Only do something if the panorama is loaded
819    if (!loaded || !config.draggable) {
820        return;
821    }
822
823    // Turn off auto-rotation if enabled
824    stopAnimation();
825
826    stopOrientation();
827    config.roll = 0;
828
829    speed.hfov = 0;
830
831    // Calculate touch position relative to top left of viewer container
832    var pos0 = mousePosition(event.targetTouches[0]);
833
834    onPointerDownPointerX = pos0.x;
835    onPointerDownPointerY = pos0.y;
836    
837    if (event.targetTouches.length == 2) {
838        // Down pointer is the center of the two fingers
839        var pos1 = mousePosition(event.targetTouches[1]);
840        onPointerDownPointerX += (pos1.x - pos0.x) * 0.5;
841        onPointerDownPointerY += (pos1.y - pos0.y) * 0.5;
842        onPointerDownPointerDist = Math.sqrt((pos0.x - pos1.x) * (pos0.x - pos1.x) +
843                                             (pos0.y - pos1.y) * (pos0.y - pos1.y));
844    }
845    isUserInteracting = true;
846    latestInteraction = Date.now();
847    
848    onPointerDownYaw = config.yaw;
849    onPointerDownPitch = config.pitch;
850
851    fireEvent('touchstart', event);
852    animateInit();
853}
854
855/**
856 * Event handler for touch movements. Pans center of view if one touch or
857 * adjusts zoom if two touches.
858 * @private
859 * @param {TouchEvent} event - Document touch move event.
860 */
861function onDocumentTouchMove(event) {
862    if (!config.draggable) {
863        return;
864    }
865
866    // Override default action
867    event.preventDefault();
868    if (loaded) {
869        latestInteraction = Date.now();
870    }
871    if (isUserInteracting && loaded) {
872        var pos0 = mousePosition(event.targetTouches[0]);
873        var clientX = pos0.x;
874        var clientY = pos0.y;
875        
876        if (event.targetTouches.length == 2 && onPointerDownPointerDist != -1) {
877            var pos1 = mousePosition(event.targetTouches[1]);
878            clientX += (pos1.x - pos0.x) * 0.5;
879            clientY += (pos1.y - pos0.y) * 0.5;
880            var clientDist = Math.sqrt((pos0.x - pos1.x) * (pos0.x - pos1.x) +
881                                       (pos0.y - pos1.y) * (pos0.y - pos1.y));
882            setHfov(config.hfov + (onPointerDownPointerDist - clientDist) * 0.1);
883            onPointerDownPointerDist = clientDist;
884        }
885
886        // The smaller the config.hfov value (the more zoomed-in the user is), the faster
887        // yaw/pitch are perceived to change on one-finger touchmove (panning) events and vice versa.
888        // To improve usability at both small and large zoom levels (config.hfov values)
889        // we introduce a dynamic pan speed coefficient.
890        //
891        // Currently this seems to *roughly* keep initial drag/pan start position close to
892        // the user's finger while panning regardless of zoom level / config.hfov value.
893        var touchmovePanSpeedCoeff = (config.hfov / 360) * config.touchPanSpeedCoeffFactor;
894
895        var yaw = (onPointerDownPointerX - clientX) * touchmovePanSpeedCoeff + onPointerDownYaw;
896        speed.yaw = (yaw - config.yaw) % 360 * 0.2;
897        config.yaw = yaw;
898
899        var pitch = (clientY - onPointerDownPointerY) * touchmovePanSpeedCoeff + onPointerDownPitch;
900        speed.pitch = (pitch - config.pitch) * 0.2;
901        config.pitch = pitch;
902    }
903}
904
905/**
906 * Event handler for end of touches. Stops panning and/or zooming.
907 * @private
908 */
909function onDocumentTouchEnd() {
910    isUserInteracting = false;
911    if (Date.now() - latestInteraction > 150) {
912        speed.pitch = speed.yaw = 0;
913    }
914    onPointerDownPointerDist = -1;
915    latestInteraction = Date.now();
916
917    fireEvent('touchend', event);
918}
919
920var pointerIDs = [],
921    pointerCoordinates = [];
922/**
923 * Event handler for touch starts in IE / Edge.
924 * @private
925 * @param {PointerEvent} event - Document pointer down event.
926 */
927function onDocumentPointerDown(event) {
928    if (event.pointerType == 'touch') {
929        // Only do something if the panorama is loaded
930        if (!loaded || !config.draggable)
931            return;
932        pointerIDs.push(event.pointerId);
933        pointerCoordinates.push({clientX: event.clientX, clientY: event.clientY});
934        event.targetTouches = pointerCoordinates;
935        onDocumentTouchStart(event);
936        event.preventDefault();
937    }
938}
939
940/**
941 * Event handler for touch moves in IE / Edge.
942 * @private
943 * @param {PointerEvent} event - Document pointer move event.
944 */
945function onDocumentPointerMove(event) {
946    if (event.pointerType == 'touch') {
947        if (!config.draggable)
948            return;
949        for (var i = 0; i < pointerIDs.length; i++) {
950            if (event.pointerId == pointerIDs[i]) {
951                pointerCoordinates[i].clientX = event.clientX;
952                pointerCoordinates[i].clientY = event.clientY;
953                event.targetTouches = pointerCoordinates;
954                onDocumentTouchMove(event);
955                event.preventDefault();
956                return;
957            }
958        }
959    }
960}
961
962/**
963 * Event handler for touch ends in IE / Edge.
964 * @private
965 * @param {PointerEvent} event - Document pointer up event.
966 */
967function onDocumentPointerUp(event) {
968    if (event.pointerType == 'touch') {
969        var defined = false;
970        for (var i = 0; i < pointerIDs.length; i++) {
971            if (event.pointerId == pointerIDs[i])
972                pointerIDs[i] = undefined;
973            if (pointerIDs[i])
974                defined = true;
975        }
976        if (!defined) {
977            pointerIDs = [];
978            pointerCoordinates = [];
979            onDocumentTouchEnd();
980        }
981        event.preventDefault();
982    }
983}
984
985/**
986 * Event handler for mouse wheel. Changes zoom.
987 * @private
988 * @param {WheelEvent} event - Document mouse wheel event.
989 */
990function onDocumentMouseWheel(event) {
991    // Only do something if the panorama is loaded and mouse wheel zoom is enabled
992    if (!loaded || (config.mouseZoom == 'fullscreenonly' && !fullscreenActive)) {
993        return;
994    }
995
996    event.preventDefault();
997
998    // Turn off auto-rotation if enabled
999    stopAnimation();
1000    latestInteraction = Date.now();
1001
1002    if (event.wheelDeltaY) {
1003        // WebKit
1004        setHfov(config.hfov - event.wheelDeltaY * 0.05);
1005        speed.hfov = event.wheelDelta < 0 ? 1 : -1;
1006    } else if (event.wheelDelta) {
1007        // Opera / Explorer 9
1008        setHfov(config.hfov - event.wheelDelta * 0.05);
1009        speed.hfov = event.wheelDelta < 0 ? 1 : -1;
1010    } else if (event.detail) {
1011        // Firefox
1012        setHfov(config.hfov + event.detail * 1.5);
1013        speed.hfov = event.detail > 0 ? 1 : -1;
1014    }
1015    animateInit();
1016}
1017
1018/**
1019 * Event handler for key presses. Updates list of currently pressed keys.
1020 * @private
1021 * @param {KeyboardEvent} event - Document key press event.
1022 */
1023function onDocumentKeyPress(event) {
1024    // Turn off auto-rotation if enabled
1025    stopAnimation();
1026    latestInteraction = Date.now();
1027
1028    stopOrientation();
1029    config.roll = 0;
1030
1031    // Record key pressed
1032    var keynumber = event.which || event.keycode;
1033
1034    // Override default action for keys that are used
1035    if (config.capturedKeyNumbers.indexOf(keynumber) < 0)
1036        return;
1037    event.preventDefault();
1038    
1039    // If escape key is pressed
1040    if (keynumber == 27) {
1041        // If in fullscreen mode
1042        if (fullscreenActive) {
1043            toggleFullscreen();
1044        }
1045    } else {
1046        // Change key
1047        changeKey(keynumber, true);
1048    }
1049}
1050
1051/**
1052 * Clears list of currently pressed keys.
1053 * @private
1054 */
1055function clearKeys() {
1056    for (var i = 0; i < 10; i++) {
1057        keysDown[i] = false;
1058    }
1059}
1060
1061/**
1062 * Event handler for key releases. Updates list of currently pressed keys.
1063 * @private
1064 * @param {KeyboardEvent} event - Document key up event.
1065 */
1066function onDocumentKeyUp(event) {
1067    // Record key pressed
1068    var keynumber = event.which || event.keycode;
1069    
1070    // Override default action for keys that are used
1071    if (config.capturedKeyNumbers.indexOf(keynumber) < 0)
1072        return;
1073    event.preventDefault();
1074    
1075    // Change key
1076    changeKey(keynumber, false);
1077}
1078
1079/**
1080 * Updates list of currently pressed keys.
1081 * @private
1082 * @param {number} keynumber - Key number.
1083 * @param {boolean} value - Whether or not key is pressed.
1084 */
1085function changeKey(keynumber, value) {
1086    var keyChanged = false;
1087    switch(keynumber) {
1088        // If minus key is released
1089        case 109: case 189: case 17: case 173:
1090            if (keysDown[0] != value) { keyChanged = true; }
1091            keysDown[0] = value; break;
1092        
1093        // If plus key is released
1094        case 107: case 187: case 16: case 61:
1095            if (keysDown[1] != value) { keyChanged = true; }
1096            keysDown[1] = value; break;
1097        
1098        // If up arrow is released
1099        case 38:
1100            if (keysDown[2] != value) { keyChanged = true; }
1101            keysDown[2] = value; break;
1102        
1103        // If "w" is released
1104        case 87:
1105            if (keysDown[6] != value) { keyChanged = true; }
1106            keysDown[6] = value; break;
1107        
1108        // If down arrow is released
1109        case 40:
1110            if (keysDown[3] != value) { keyChanged = true; }
1111            keysDown[3] = value; break;
1112        
1113        // If "s" is released
1114        case 83:
1115            if (keysDown[7] != value) { keyChanged = true; }
1116            keysDown[7] = value; break;
1117        
1118        // If left arrow is released
1119        case 37:
1120            if (keysDown[4] != value) { keyChanged = true; }
1121            keysDown[4] = value; break;
1122        
1123        // If "a" is released
1124        case 65:
1125            if (keysDown[8] != value) { keyChanged = true; }
1126            keysDown[8] = value; break;
1127        
1128        // If right arrow is released
1129        case 39:
1130            if (keysDown[5] != value) { keyChanged = true; }
1131            keysDown[5] = value; break;
1132        
1133        // If "d" is released
1134        case 68:
1135            if (keysDown[9] != value) { keyChanged = true; }
1136            keysDown[9] = value;
1137    }
1138    
1139    if (keyChanged && value) {
1140        if (typeof performance !== 'undefined' && performance.now()) {
1141            prevTime = performance.now();
1142        } else {
1143            prevTime = Date.now();
1144        }
1145        animateInit();
1146    }
1147}
1148
1149/**
1150 * Pans and/or zooms panorama based on currently pressed keys. Also handles
1151 * panorama "inertia" and auto rotation.
1152 * @private
1153 */
1154function keyRepeat() {
1155    // Only do something if the panorama is loaded
1156    if (!loaded) {
1157        return;
1158    }
1159
1160    var isKeyDown = false;
1161
1162    var prevPitch = config.pitch;
1163    var prevYaw = config.yaw;
1164    var prevZoom = config.hfov;
1165    
1166    var newTime;
1167    if (typeof performance !== 'undefined' && performance.now()) {
1168        newTime = performance.now();
1169    } else {
1170        newTime = Date.now();
1171    }
1172    if (prevTime === undefined) {
1173        prevTime = newTime;
1174    }
1175    var diff = (newTime - prevTime) * config.hfov / 1700;
1176    diff = Math.min(diff, 1.0);
1177    
1178    // If minus key is down
1179    if (keysDown[0] && config.keyboardZoom === true) {
1180        setHfov(config.hfov + (speed.hfov * 0.8 + 0.5) * diff);
1181        isKeyDown = true;
1182    }
1183    
1184    // If plus key is down
1185    if (keysDown[1] && config.keyboardZoom === true) {
1186        setHfov(config.hfov + (speed.hfov * 0.8 - 0.2) * diff);
1187        isKeyDown = true;
1188    }
1189    
1190    // If up arrow or "w" is down
1191    if (keysDown[2] || keysDown[6]) {
1192        // Pan up
1193        config.pitch += (speed.pitch * 0.8 + 0.2) * diff;
1194        isKeyDown = true;
1195    }
1196    
1197    // If down arrow or "s" is down
1198    if (keysDown[3] || keysDown[7]) {
1199        // Pan down
1200        config.pitch += (speed.pitch * 0.8 - 0.2) * diff;
1201        isKeyDown = true;
1202    }
1203    
1204    // If left arrow or "a" is down
1205    if (keysDown[4] || keysDown[8]) {
1206        // Pan left
1207        config.yaw += (speed.yaw * 0.8 - 0.2) * diff;
1208        isKeyDown = true;
1209    }
1210    
1211    // If right arrow or "d" is down
1212    if (keysDown[5] || keysDown[9]) {
1213        // Pan right
1214        config.yaw += (speed.yaw * 0.8 + 0.2) * diff;
1215        isKeyDown = true;
1216    }
1217
1218    if (isKeyDown)
1219        latestInteraction = Date.now();
1220
1221    // If auto-rotate
1222    if (config.autoRotate) {
1223        // Pan
1224        if (newTime - prevTime > 0.001) {
1225            var timeDiff = (newTime - prevTime) / 1000;
1226            var yawDiff = (speed.yaw / timeDiff * diff - config.autoRotate * 0.2) * timeDiff;
1227            yawDiff = (-config.autoRotate > 0 ? 1 : -1) * Math.min(Math.abs(config.autoRotate * timeDiff), Math.abs(yawDiff));
1228            config.yaw += yawDiff;
1229        }
1230        
1231        // Deal with stopping auto rotation after a set delay
1232        if (config.autoRotateStopDelay) {
1233            config.autoRotateStopDelay -= newTime - prevTime;
1234            if (config.autoRotateStopDelay <= 0) {
1235                config.autoRotateStopDelay = false;
1236                autoRotateSpeed = config.autoRotate;
1237                config.autoRotate = 0;
1238            }
1239        }
1240    }
1241
1242    // Animated moves
1243    if (animatedMove.pitch) {
1244        animateMove('pitch');
1245        prevPitch = config.pitch;
1246    }
1247    if (animatedMove.yaw) {
1248        animateMove('yaw');
1249        prevYaw = config.yaw;
1250    }
1251    if (animatedMove.hfov) {
1252        animateMove('hfov');
1253        prevZoom = config.hfov;
1254    }
1255
1256    // "Inertia"
1257    if (diff > 0 && !config.autoRotate) {
1258        // "Friction"
1259        var slowDownFactor = 1 - config.friction;
1260
1261        // Yaw
1262        if (!keysDown[4] && !keysDown[5] && !keysDown[8] && !keysDown[9] && !animatedMove.yaw) {
1263            config.yaw += speed.yaw * diff * slowDownFactor;
1264        }
1265        // Pitch
1266        if (!keysDown[2] && !keysDown[3] && !keysDown[6] && !keysDown[7] && !animatedMove.pitch) {
1267            config.pitch += speed.pitch * diff * slowDownFactor;
1268        }
1269        // Zoom
1270        if (!keysDown[0] && !keysDown[1] && !animatedMove.hfov) {
1271            setHfov(config.hfov + speed.hfov * diff * slowDownFactor);
1272        }
1273    }
1274
1275    prevTime = newTime;
1276    if (diff > 0) {
1277        speed.yaw = speed.yaw * 0.8 + (config.yaw - prevYaw) / diff * 0.2;
1278        speed.pitch = speed.pitch * 0.8 + (config.pitch - prevPitch) / diff * 0.2;
1279        speed.hfov = speed.hfov * 0.8 + (config.hfov - prevZoom) / diff * 0.2;
1280        
1281        // Limit speed
1282        var maxSpeed = config.autoRotate ? Math.abs(config.autoRotate) : 5;
1283        speed.yaw = Math.min(maxSpeed, Math.max(speed.yaw, -maxSpeed));
1284        speed.pitch = Math.min(maxSpeed, Math.max(speed.pitch, -maxSpeed));
1285        speed.hfov = Math.min(maxSpeed, Math.max(speed.hfov, -maxSpeed));
1286    }
1287    
1288    // Stop movement if opposite controls are pressed
1289    if (keysDown[0] && keysDown[1]) {
1290        speed.hfov = 0;
1291    }
1292    if ((keysDown[2] || keysDown[6]) && (keysDown[3] || keysDown[7])) {
1293        speed.pitch = 0;
1294    }
1295    if ((keysDown[4] || keysDown[8]) && (keysDown[5] || keysDown[9])) {
1296        speed.yaw = 0;
1297    }
1298}
1299
1300/**
1301 * Animates moves.
1302 * @param {string} axis - Axis to animate
1303 * @private
1304 */
1305function animateMove(axis) {
1306    var t = animatedMove[axis];
1307    var normTime = Math.min(1, Math.max((Date.now() - t.startTime) / 1000 / (t.duration / 1000), 0));
1308    var result = t.startPosition + config.animationTimingFunction(normTime) * (t.endPosition - t.startPosition);
1309    if ((t.endPosition > t.startPosition && result >= t.endPosition) ||
1310        (t.endPosition < t.startPosition && result <= t.endPosition) ||
1311        t.endPosition === t.startPosition) {
1312        result = t.endPosition;
1313        speed[axis] = 0;
1314        delete animatedMove[axis];
1315    }
1316    config[axis] = result;
1317}
1318
1319/**
1320 * @param {number} t - Normalized time in animation
1321 * @return {number} Position in animation
1322 * @private
1323 */
1324function timingFunction(t) {
1325    // easeInOutQuad from https://gist.github.com/gre/1650294
1326    return t < 0.5 ? 2*t*t : -1+(4-2*t)*t;
1327}
1328
1329/**
1330 * Event handler for document resizes. Updates viewer size and rerenders view.
1331 * @private
1332 */
1333function onDocumentResize() {
1334    // Resize panorama renderer (moved to onFullScreenChange)
1335    //renderer.resize();
1336    //animateInit();
1337
1338    // Kludge to deal with WebKit regression: https://bugs.webkit.org/show_bug.cgi?id=93525
1339    onFullScreenChange('resize');
1340}
1341
1342/**
1343 * Initializes animation.
1344 * @private
1345 */
1346function animateInit() {
1347    if (animating) {
1348        return;
1349    }
1350    animating = true;
1351    animate();
1352}
1353
1354/**
1355 * Animates view, using requestAnimationFrame to trigger rendering.
1356 * @private
1357 */
1358function animate() {
1359    if (destroyed) {
1360        return;
1361    }
1362
1363    render();
1364    if (autoRotateStart)
1365        clearTimeout(autoRotateStart);
1366    if (isUserInteracting || orientation === true) {
1367        requestAnimationFrame(animate);
1368    } else if (keysDown[0] || keysDown[1] || keysDown[2] || keysDown[3] ||
1369        keysDown[4] || keysDown[5] || keysDown[6] || keysDown[7] ||
1370        keysDown[8] || keysDown[9] || config.autoRotate ||
1371        animatedMove.pitch || animatedMove.yaw || animatedMove.hfov ||
1372        Math.abs(speed.yaw) > 0.01 || Math.abs(speed.pitch) > 0.01 ||
1373        Math.abs(speed.hfov) > 0.01) {
1374
1375        keyRepeat();
1376        if (config.autoRotateInactivityDelay >= 0 && autoRotateSpeed &&
1377            Date.now() - latestInteraction > config.autoRotateInactivityDelay &&
1378            !config.autoRotate) {
1379            config.autoRotate = autoRotateSpeed;
1380            _this.lookAt(origPitch, undefined, origHfov, 3000);
1381        }
1382        requestAnimationFrame(animate);
1383    } else if (renderer && (renderer.isLoading() || (config.dynamic === true && update))) {
1384        requestAnimationFrame(animate);
1385    } else {
1386        fireEvent('animatefinished', {pitch: _this.getPitch(), yaw: _this.getYaw(), hfov: _this.getHfov()});
1387        animating = false;
1388        prevTime = undefined;
1389        var autoRotateStartTime = config.autoRotateInactivityDelay -
1390            (Date.now() - latestInteraction);
1391        if (autoRotateStartTime > 0) {
1392            autoRotateStart = setTimeout(function() {
1393                config.autoRotate = autoRotateSpeed;
1394                _this.lookAt(origPitch, undefined, origHfov, 3000);
1395                animateInit();
1396            }, autoRotateStartTime);
1397        } else if (config.autoRotateInactivityDelay >= 0 && autoRotateSpeed) {
1398            config.autoRotate = autoRotateSpeed;
1399            _this.lookAt(origPitch, undefined, origHfov, 3000);
1400            animateInit();
1401        }
1402    }
1403}
1404
1405/**
1406 * Renders panorama view.
1407 * @private
1408 */
1409function render() {
1410    var tmpyaw;
1411
1412    if (loaded) {
1413        var canvas = renderer.getCanvas();
1414
1415        if (config.autoRotate !== false) {
1416            // When auto-rotating this check needs to happen first (see issue #764)
1417            if (config.yaw > 360) {
1418                config.yaw -= 360;
1419            } else if (config.yaw < -360) {
1420                config.yaw += 360;
1421            }
1422        }
1423
1424        // Keep a tmp value of yaw for autoRotate comparison later
1425        tmpyaw = config.yaw;
1426
1427        // Optionally avoid showing background (empty space) on left or right by adapting min/max yaw
1428        var hoffcut = 0,
1429            voffcut = 0;
1430        if (config.avoidShowingBackground) {
1431            var hfov2 = config.hfov / 2,
1432                vfov2 = Math.atan2(Math.tan(hfov2 / 180 * Math.PI), (canvas.width / canvas.height)) * 180 / Math.PI,
1433                transposed = config.vaov > config.haov;
1434            if (transposed) {
1435                voffcut = vfov2 * (1 - Math.min(Math.cos((config.pitch - hfov2) / 180 * Math.PI),
1436                                                Math.cos((config.pitch + hfov2) / 180 * Math.PI)));
1437            } else {
1438                hoffcut = hfov2 * (1 - Math.min(Math.cos((config.pitch - vfov2) / 180 * Math.PI),
1439                                                Math.cos((config.pitch + vfov2) / 180 * Math.PI)));
1440            }
1441        }
1442
1443        // Ensure the yaw is within min and max allowed
1444        var yawRange = config.maxYaw - config.minYaw,
1445            minYaw = -180,
1446            maxYaw = 180;
1447        if (yawRange < 360) {
1448            minYaw = config.minYaw + config.hfov / 2 + hoffcut;
1449            maxYaw = config.maxYaw - config.hfov / 2 - hoffcut;
1450            if (yawRange < config.hfov) {
1451                // Lock yaw to average of min and max yaw when both can be seen at once
1452                minYaw = maxYaw = (minYaw + maxYaw) / 2;
1453            }
1454            config.yaw = Math.max(minYaw, Math.min(maxYaw, config.yaw));
1455        }
1456        
1457        if (!(config.autoRotate !== false)) {
1458            // When not auto-rotating, this check needs to happen after the
1459            // previous check (see issue #698)
1460            if (config.yaw > 360) {
1461                config.yaw -= 360;
1462            } else if (config.yaw < -360) {
1463                config.yaw += 360;
1464            }
1465        }
1466
1467        // Check if we autoRotate in a limited by min and max yaw
1468        // If so reverse direction
1469        if (config.autoRotate !== false && tmpyaw != config.yaw &&
1470            prevTime !== undefined) { // this condition prevents changing the direction initially
1471            config.autoRotate *= -1;
1472        }
1473
1474        // Ensure the calculated pitch is within min and max allowed
1475        var vfov = 2 * Math.atan(Math.tan(config.hfov / 180 * Math.PI * 0.5) /
1476            (canvas.width / canvas.height)) / Math.PI * 180;
1477        var minPitch = config.minPitch + vfov / 2,
1478            maxPitch = config.maxPitch - vfov / 2;
1479        var pitchRange = config.maxPitch - config.minPitch;
1480        if (pitchRange < vfov) {
1481            // Lock pitch to average of min and max pitch when both can be seen at once
1482            minPitch = maxPitch = (minPitch + maxPitch) / 2;
1483        }
1484        if (isNaN(minPitch))
1485            minPitch = -90;
1486        if (isNaN(maxPitch))
1487            maxPitch = 90;
1488        config.pitch = Math.max(minPitch, Math.min(maxPitch, config.pitch));
1489        
1490        renderer.render(config.pitch * Math.PI / 180, config.yaw * Math.PI / 180, config.hfov * Math.PI / 180, {roll: config.roll * Math.PI / 180});
1491        
1492        renderHotSpots();
1493        
1494        // Update compass
1495        if (config.compass) {
1496            compass.style.transform = 'rotate(' + (-config.yaw - config.northOffset) + 'deg)';
1497            compass.style.webkitTransform = 'rotate(' + (-config.yaw - config.northOffset) + 'deg)';
1498        }
1499    }
1500}
1501
1502/**
1503 * Creates a new quaternion.
1504 * @private
1505 * @constructor
1506 * @param {Number} w - W value
1507 * @param {Number} x - X value
1508 * @param {Number} y - Y value
1509 * @param {Number} z - Z value
1510 */
1511function Quaternion(w, x, y, z) {
1512    this.w = w;
1513    this.x = x;
1514    this.y = y;
1515    this.z = z;
1516}
1517
1518/**
1519 * Multiplies quaternions.
1520 * @private
1521 * @param {Quaternion} q - Quaternion to multiply
1522 * @returns {Quaternion} Result of multiplication
1523 */
1524Quaternion.prototype.multiply = function(q) {
1525    return new Quaternion(this.w*q.w - this.x*q.x - this.y*q.y - this.z*q.z,
1526                          this.x*q.w + this.w*q.x + this.y*q.z - this.z*q.y,
1527                          this.y*q.w + this.w*q.y + this.z*q.x - this.x*q.z,
1528                          this.z*q.w + this.w*q.z + this.x*q.y - this.y*q.x);
1529};
1530
1531/**
1532 * Converts quaternion to Euler angles.
1533 * @private
1534 * @returns {Number[]} [phi angle, theta angle, psi angle]
1535 */
1536Quaternion.prototype.toEulerAngles = function() {
1537    var phi = Math.atan2(2 * (this.w * this.x + this.y * this.z),
1538                         1 - 2 * (this.x * this.x + this.y * this.y)),
1539        theta = Math.asin(2 * (this.w * this.y - this.z * this.x)),
1540        psi = Math.atan2(2 * (this.w * this.z + this.x * this.y),
1541                         1 - 2 * (this.y * this.y + this.z * this.z));
1542    return [phi, theta, psi];
1543};
1544
1545/**
1546 * Converts device orientation API Tait-Bryan angles to a quaternion.
1547 * @private
1548 * @param {Number} alpha - Alpha angle (in degrees)
1549 * @param {Number} beta - Beta angle (in degrees)
1550 * @param {Number} gamma - Gamma angle (in degrees)
1551 * @returns {Quaternion} Orientation quaternion
1552 */
1553function taitBryanToQuaternion(alpha, beta, gamma) {
1554    var r = [beta ? beta * Math.PI / 180 / 2 : 0,
1555             gamma ? gamma * Math.PI / 180 / 2 : 0,
1556             alpha ? alpha * Math.PI / 180 / 2 : 0];
1557    var c = [Math.cos(r[0]), Math.cos(r[1]), Math.cos(r[2])],
1558        s = [Math.sin(r[0]), Math.sin(r[1]), Math.sin(r[2])];
1559
1560    return new Quaternion(c[0]*c[1]*c[2] - s[0]*s[1]*s[2],
1561                          s[0]*c[1]*c[2] - c[0]*s[1]*s[2],
1562                          c[0]*s[1]*c[2] + s[0]*c[1]*s[2],
1563                          c[0]*c[1]*s[2] + s[0]*s[1]*c[2]);
1564}
1565
1566/**
1567 * Computes current device orientation quaternion from device orientation API
1568 * Tait-Bryan angles.
1569 * @private
1570 * @param {Number} alpha - Alpha angle (in degrees)
1571 * @param {Number} beta - Beta angle (in degrees)
1572 * @param {Number} gamma - Gamma angle (in degrees)
1573 * @returns {Quaternion} Orientation quaternion
1574 */
1575function computeQuaternion(alpha, beta, gamma) {
1576    // Convert Tait-Bryan angles to quaternion
1577    var quaternion = taitBryanToQuaternion(alpha, beta, gamma);
1578    // Apply world transform
1579    quaternion = quaternion.multiply(new Quaternion(Math.sqrt(0.5), -Math.sqrt(0.5), 0, 0));
1580    // Apply screen transform
1581    var angle = window.orientation ? -window.orientation * Math.PI / 180 / 2 : 0;
1582    return quaternion.multiply(new Quaternion(Math.cos(angle), 0, -Math.sin(angle), 0));
1583}
1584
1585/**
1586 * Event handler for device orientation API. Controls pointing.
1587 * @private
1588 * @param {DeviceOrientationEvent} event - Device orientation event.
1589 */
1590function orientationListener(e) {
1591    var q = computeQuaternion(e.alpha, e.beta, e.gamma).toEulerAngles();
1592    if (typeof(orientation) == 'number' && orientation < 10) {
1593        // This kludge is necessary because iOS sometimes provides a few stale
1594        // device orientation events when the listener is removed and then
1595        // readded. Thus, we skip the first 10 events to prevent this from
1596        // causing problems.
1597        orientation += 1;
1598    } else if (orientation === 10) {
1599        // Record starting yaw to prevent jumping
1600        orientationYawOffset = q[2] / Math.PI * 180 + config.yaw;
1601        orientation = true;
1602        requestAnimationFrame(animate);
1603    } else {
1604        config.pitch = q[0] / Math.PI * 180;
1605        config.roll = -q[1] / Math.PI * 180;
1606        config.yaw = -q[2] / Math.PI * 180 + orientationYawOffset;
1607    }
1608}
1609
1610/**
1611 * Initializes renderer.
1612 * @private
1613 */
1614function renderInit() {
1615    try {
1616        var params = {};
1617        if (config.horizonPitch !== undefined)
1618            params.horizonPitch = config.horizonPitch * Math.PI / 180;
1619        if (config.horizonRoll !== undefined)
1620            params.horizonRoll = config.horizonRoll * Math.PI / 180;
1621        if (config.backgroundColor !== undefined)
1622            params.backgroundColor = config.backgroundColor;
1623        renderer.init(panoImage, config.type, config.dynamic, config.haov * Math.PI / 180, config.vaov * Math.PI / 180, config.vOffset * Math.PI / 180, renderInitCallback, params);
1624        if (config.dynamic !== true) {
1625            // Allow image to be garbage collected
1626            panoImage = undefined;
1627        }
1628    } catch(event) {
1629        // Panorama not loaded
1630        
1631        // Display error if there is a bad texture
1632        if (event.type == 'webgl error' || event.type == 'no webgl') {
1633            anError();
1634        } else if (event.type == 'webgl size error') {
1635            anError(config.strings.textureSizeError.replace('%s', event.width).replace('%s', event.maxWidth));
1636        } else {
1637            anError(config.strings.unknownError);
1638            throw event;
1639        }
1640    }
1641}
1642
1643/**
1644 * Triggered when render initialization finishes. Handles fading between
1645 * scenes as well as showing the compass and hotspots and hiding the loading
1646 * display.
1647 * @private
1648 */
1649function renderInitCallback() {
1650    // Fade if specified
1651    if (config.sceneFadeDuration && renderer.fadeImg !== undefined) {
1652        renderer.fadeImg.style.opacity = 0;
1653        // Remove image
1654        var fadeImg = renderer.fadeImg;
1655        delete renderer.fadeImg;
1656        setTimeout(function() {
1657            renderContainer.removeChild(fadeImg);
1658            fireEvent('scenechangefadedone');
1659        }, config.sceneFadeDuration);
1660    }
1661    
1662    // Show compass if applicable
1663    if (config.compass) {
1664        compass.style.display = 'inline';
1665    } else {
1666        compass.style.display = 'none';
1667    }
1668    
1669    // Show hotspots
1670    createHotSpots();
1671    
1672    // Hide loading display
1673    infoDisplay.load.box.style.display = 'none';
1674    if (preview !== undefined) {
1675        renderContainer.removeChild(preview);
1676        preview = undefined;
1677    }
1678    loaded = true;
1679
1680    fireEvent('load');
1681    
1682    animateInit();
1683}
1684
1685/**
1686 * Creates hot spot element for the current scene.
1687 * @private
1688 * @param {Object} hs - The configuration for the hotspot
1689 */
1690function createHotSpot(hs) {
1691    // Make sure hot spot pitch and yaw are numbers
1692    hs.pitch = Number(hs.pitch) || 0;
1693    hs.yaw = Number(hs.yaw) || 0;
1694
1695    var div = document.createElement('div');
1696    div.className = 'pnlm-hotspot-base';
1697    if (hs.cssClass)
1698        div.className += ' ' + hs.cssClass;
1699    else
1700        div.className += ' pnlm-hotspot pnlm-sprite pnlm-' + escapeHTML(hs.type);
1701
1702    var span = document.createElement('span');
1703    if (hs.text)
1704        span.innerHTML = escapeHTML(hs.text);
1705
1706    var a;
1707    if (hs.video) {
1708        var video = document.createElement('video'),
1709            vidp = hs.video;
1710        if (config.basePath && !absoluteURL(vidp))
1711            vidp = config.basePath + vidp;
1712        video.src = sanitizeURL(vidp);
1713        video.controls = true;
1714        video.style.width = hs.width + 'px';
1715        renderContainer.appendChild(div);
1716        span.appendChild(video);
1717    } else if (hs.image) {
1718        var imgp = hs.image;
1719        if (config.basePath && !absoluteURL(imgp))
1720            imgp = config.basePath + imgp;
1721        a = document.createElement('a');
1722        a.href = sanitizeURL(hs.URL ? hs.URL : imgp, true);
1723        a.target = '_blank';
1724        span.appendChild(a);
1725        var image = document.createElement('img');
1726        image.src = sanitizeURL(imgp);
1727        image.style.width = hs.width + 'px';
1728        image.style.paddingTop = '5px';
1729        renderContainer.appendChild(div);
1730        a.appendChild(image);
1731        span.style.maxWidth = 'initial';
1732    } else if (hs.URL) {
1733        a = document.createElement('a');
1734        a.href = sanitizeURL(hs.URL, true);
1735        if (hs.attributes) {
1736            for (var key in hs.attributes) {
1737                a.setAttribute(key, hs.attributes[key]);
1738            }
1739        } else {
1740            a.target = '_blank';
1741        }
1742        renderContainer.appendChild(a);
1743        div.className += ' pnlm-pointer';
1744        span.className += ' pnlm-pointer';
1745        a.appendChild(div);
1746    } else {
1747        if (hs.sceneId) {
1748            div.onclick = div.ontouchend = function() {
1749                if (!div.clicked) {
1750                    div.clicked = true;
1751                    loadScene(hs.sceneId, hs.targetPitch, hs.targetYaw, hs.targetHfov);
1752                }
1753                return false;
1754            };
1755            div.className += ' pnlm-pointer';
1756            span.className += ' pnlm-pointer';
1757        }
1758        renderContainer.appendChild(div);
1759    }
1760
1761    if (hs.createTooltipFunc) {
1762        hs.createTooltipFunc(div, hs.createTooltipArgs);
1763    } else if (hs.text || hs.video || hs.image) {
1764        div.classList.add('pnlm-tooltip');
1765        div.appendChild(span);
1766        span.style.width = span.scrollWidth - 20 + 'px';
1767        span.style.marginLeft = -(span.scrollWidth - div.offsetWidth) / 2 + 'px';
1768        span.style.marginTop = -span.scrollHeight - 12 + 'px';
1769    }
1770    if (hs.clickHandlerFunc) {
1771        div.addEventListener('click', function(e) {
1772            hs.clickHandlerFunc(e, hs.clickHandlerArgs);
1773        }, 'false');
1774        div.className += ' pnlm-pointer';
1775        span.className += ' pnlm-pointer';
1776    }
1777    hs.div = div;
1778}
1779
1780/**
1781 * Creates hot spot elements for the current scene.
1782 * @private
1783 */
1784function createHotSpots() {
1785    if (hotspotsCreated) return;
1786
1787    if (!config.hotSpots) {
1788        config.hotSpots = [];
1789    } else {
1790        // Sort by pitch so tooltip is never obscured by another hot spot
1791        config.hotSpots = config.hotSpots.sort(function(a, b) {
1792            return a.pitch < b.pitch;
1793        });
1794        config.hotSpots.forEach(createHotSpot);
1795    }
1796    hotspotsCreated = true;
1797    renderHotSpots();
1798}
1799
1800/**
1801 * Destroys currently created hot spot elements.
1802 * @private
1803 */
1804function destroyHotSpots() {
1805    var hs = config.hotSpots;
1806    hotspotsCreated = false;
1807    delete config.hotSpots;
1808    if (hs) {
1809        for (var i = 0; i < hs.length; i++) {
1810            var current = hs[i].div;
1811            if (current) {
1812                while (current.parentNode && current.parentNode != renderContainer) {
1813                    current = current.parentNode;
1814                }
1815                renderContainer.removeChild(current);
1816            }
1817            delete hs[i].div;
1818        }
1819    }
1820}
1821
1822/**
1823 * Renders hot spot, updating its position and visibility.
1824 * @private
1825 */
1826function renderHotSpot(hs) {
1827    var hsPitchSin = Math.sin(hs.pitch * Math.PI / 180),
1828        hsPitchCos = Math.cos(hs.pitch * Math.PI / 180),
1829        configPitchSin = Math.sin(config.pitch * Math.PI / 180),
1830        configPitchCos = Math.cos(config.pitch * Math.PI / 180),
1831        yawCos = Math.cos((-hs.yaw + config.yaw) * Math.PI / 180);
1832    var z = hsPitchSin * configPitchSin + hsPitchCos * yawCos * configPitchCos;
1833    if ((hs.yaw <= 90 && hs.yaw > -90 && z <= 0) ||
1834      ((hs.yaw > 90 || hs.yaw <= -90) && z <= 0)) {
1835        hs.div.style.visibility = 'hidden';
1836    } else {
1837        var yawSin = Math.sin((-hs.yaw + config.yaw) * Math.PI / 180),
1838            hfovTan = Math.tan(config.hfov * Math.PI / 360);
1839        hs.div.style.visibility = 'visible';
1840        // Subpixel rendering doesn't work in Firefox
1841        // https://bugzilla.mozilla.org/show_bug.cgi?id=739176
1842        var canvas = renderer.getCanvas(),
1843            canvasWidth = canvas.clientWidth,
1844            canvasHeight = canvas.clientHeight;
1845        var coord = [-canvasWidth / hfovTan * yawSin * hsPitchCos / z / 2,
1846            -canvasWidth / hfovTan * (hsPitchSin * configPitchCos -
1847            hsPitchCos * yawCos * configPitchSin) / z / 2];
1848        // Apply roll
1849        var rollSin = Math.sin(config.roll * Math.PI / 180),
1850            rollCos = Math.cos(config.roll * Math.PI / 180);
1851        coord = [coord[0] * rollCos - coord[1] * rollSin,
1852                 coord[0] * rollSin + coord[1] * rollCos];
1853        // Apply transform
1854        coord[0] += (canvasWidth - hs.div.offsetWidth) / 2;
1855        coord[1] += (canvasHeight - hs.div.offsetHeight) / 2;
1856        var transform = 'translate(' + coord[0] + 'px, ' + coord[1] +
1857            'px) translateZ(9999px) rotate(' + config.roll + 'deg)';
1858        if (hs.scale) {
1859            transform += ' scale(' + (origHfov/config.hfov) / z + ')';
1860        }
1861        hs.div.style.webkitTransform = transform;
1862        hs.div.style.MozTransform = transform;
1863        hs.div.style.transform = transform;
1864    }
1865}
1866
1867/**
1868 * Renders hot spots, updating their positions and visibility.
1869 * @private
1870 */
1871function renderHotSpots() {
1872    config.hotSpots.forEach(renderHotSpot);
1873}
1874
1875/**
1876 * Merges a scene configuration into the current configuration.
1877 * @private
1878 * @param {string} sceneId - Identifier of scene configuration to merge in.
1879 */
1880function mergeConfig(sceneId) {
1881    config = {};
1882    var k, s;
1883    var photoSphereExcludes = ['haov', 'vaov', 'vOffset', 'northOffset', 'horizonPitch', 'horizonRoll'];
1884    specifiedPhotoSphereExcludes = [];
1885    
1886    // Merge default config
1887    for (k in defaultConfig) {
1888        if (defaultConfig.hasOwnProperty(k)) {
1889            config[k] = defaultConfig[k];
1890        }
1891    }
1892    
1893    // Merge default scene config
1894    for (k in initialConfig.default) {
1895        if (initialConfig.default.hasOwnProperty(k)) {
1896            if (k == 'strings') {
1897                for (s in initialConfig.default.strings) {
1898                    if (initialConfig.default.strings.hasOwnProperty(s)) {
1899                        config.strings[s] = escapeHTML(initialConfig.default.strings[s]);
1900                    }
1901                }
1902            } else {
1903                config[k] = initialConfig.default[k];
1904                if (photoSphereExcludes.indexOf(k) >= 0) {
1905                    specifiedPhotoSphereExcludes.push(k);
1906                }
1907            }
1908        }
1909    }
1910    
1911    // Merge current scene config
1912    if ((sceneId !== null) && (sceneId !== '') && (initialConfig.scenes) && (initialConfig.scenes[sceneId])) {
1913        var scene = initialConfig.scenes[sceneId];
1914        for (k in scene) {
1915            if (scene.hasOwnProperty(k)) {
1916                if (k == 'strings') {
1917                    for (s in scene.strings) {
1918                        if (scene.strings.hasOwnProperty(s)) {
1919                            config.strings[s] = escapeHTML(scene.strings[s]);
1920                        }
1921                    }
1922                } else {
1923                    config[k] = scene[k];
1924                    if (photoSphereExcludes.indexOf(k) >= 0) {
1925                        specifiedPhotoSphereExcludes.push(k);
1926                    }
1927                }
1928            }
1929        }
1930        config.scene = sceneId;
1931    }
1932    
1933    // Merge initial config
1934    for (k in initialConfig) {
1935        if (initialConfig.hasOwnProperty(k)) {
1936            if (k == 'strings') {
1937                for (s in initialConfig.strings) {
1938                    if (initialConfig.strings.hasOwnProperty(s)) {
1939                        config.strings[s] = escapeHTML(initialConfig.strings[s]);
1940                    }
1941                }
1942            } else {
1943                config[k] = initialConfig[k];
1944                if (photoSphereExcludes.indexOf(k) >= 0) {
1945                    specifiedPhotoSphereExcludes.push(k);
1946                }
1947            }
1948        }
1949    }
1950}
1951
1952/**
1953 * Processes configuration options.
1954 * @param {boolean} [isPreview] - Whether or not the preview is being displayed
1955 * @private
1956 */
1957function processOptions(isPreview) {
1958    isPreview = isPreview ? isPreview : false;
1959
1960    // Process preview first so it always loads before the browser hits its
1961    // maximum number of connections to a server as can happen with cubic
1962    // panoramas
1963    if (isPreview && 'preview' in config) {
1964        var p = config.preview;
1965        if (config.basePath && !absoluteURL(p))
1966            p = config.basePath + p;
1967        preview = document.createElement('div');
1968        preview.className = 'pnlm-preview-img';
1969        preview.style.backgroundImage = "url('" + sanitizeURLForCss(p) + "')";
1970        renderContainer.appendChild(preview);
1971    }
1972
1973    // Handle different preview values
1974    var title = config.title,
1975        author = config.author;
1976    if (isPreview) {
1977        if ('previewTitle' in config)
1978            config.title = config.previewTitle;
1979        if ('previewAuthor' in config)
1980            config.author = config.previewAuthor;
1981    }
1982
1983    // Reset title / author display
1984    if (!config.hasOwnProperty('title'))
1985        infoDisplay.title.innerHTML = '';
1986    if (!config.hasOwnProperty('author'))
1987        infoDisplay.author.innerHTML = '';
1988    if (!config.hasOwnProperty('title') && !config.hasOwnProperty('author'))
1989        infoDisplay.container.style.display = 'none';
1990
1991    // Fill in load button label and loading box text
1992    controls.load.innerHTML = '<p>' + config.strings.loadButtonLabel + '</p>';
1993    infoDisplay.load.boxp.innerHTML = config.strings.loadingLabel;
1994
1995    // Process other options
1996    for (var key in config) {
1997      if (config.hasOwnProperty(key)) {
1998        switch(key) {
1999            case 'title':
2000                infoDisplay.title.innerHTML = escapeHTML(config[key]);
2001                infoDisplay.container.style.display = 'inline';
2002                break;
2003            
2004            case 'author':
2005                var authorText = escapeHTML(config[key]);
2006                if (config.authorURL) {
2007                    var authorLink = document.createElement('a');
2008                    authorLink.href = sanitizeURL(config['authorURL'], true);
2009                    authorLink.target = '_blank';
2010                    authorLink.innerHTML = escapeHTML(config[key]);
2011                    authorText = authorLink.outerHTML;
2012                }
2013                infoDisplay.author.innerHTML = config.strings.bylineLabel.replace('%s', authorText);
2014                infoDisplay.container.style.display = 'inline';
2015                break;
2016            
2017            case 'fallback':
2018                var link = document.createElement('a');
2019                link.href = sanitizeURL(config[key], true);
2020                link.target = '_blank';
2021                link.textContent = 'Click here to view this panorama in an alternative viewer.';
2022                var message = document.createElement('p');
2023                message.textContent = 'Your browser does not support WebGL.';
2024                message.appendChild(document.createElement('br'));
2025                message.appendChild(link);
2026                infoDisplay.errorMsg.innerHTML = ''; // Removes all children nodes
2027                infoDisplay.errorMsg.appendChild(message);
2028                break;
2029            
2030            case 'hfov':
2031                setHfov(Number(config[key]));
2032                break;
2033            
2034            case 'autoLoad':
2035                if (config[key] === true && renderer === undefined) {
2036                    // Show loading box
2037                    infoDisplay.load.box.style.display = 'inline';
2038                    // Hide load button
2039                    controls.load.style.display = 'none';
2040                    // Initialize
2041                    init();
2042                }
2043                break;
2044            
2045            case 'showZoomCtrl':
2046                if (config[key] && config.showControls != false) {
2047                    // Show zoom controls
2048                    controls.zoom.style.display = 'block';
2049                } else {
2050                    // Hide zoom controls
2051                    controls.zoom.style.display = 'none';
2052                }
2053                break;
2054
2055            case 'showFullscreenCtrl':
2056                if (config[key] && config.showControls != false && ('fullscreen' in document || 'mozFullScreen' in document ||
2057                    'webkitIsFullScreen' in document || 'msFullscreenElement' in document)) {
2058                    
2059                    // Show fullscreen control
2060                    controls.fullscreen.style.display = 'block';
2061                } else {
2062                    // Hide fullscreen control
2063                    controls.fullscreen.style.display = 'none';
2064                }
2065                break;
2066
2067            case 'hotSpotDebug':
2068                if (config[key])
2069                    hotSpotDebugIndicator.style.display = 'block';
2070                else
2071                    hotSpotDebugIndicator.style.display = 'none';
2072                break;
2073
2074            case 'showControls':
2075                if (!config[key]) {
2076                    controls.orientation.style.display = 'none';
2077                    controls.zoom.style.display = 'none';
2078                    controls.fullscreen.style.display = 'none';
2079                }
2080                break;
2081
2082            case 'orientationOnByDefault':
2083                if (config[key])
2084                    startOrientation();
2085                break;
2086        }
2087      }
2088    }
2089
2090    if (isPreview) {
2091        // Restore original values if changed for preview
2092        if (title)
2093            config.title = title;
2094        else
2095            delete config.title;
2096        if (author)
2097            config.author = author;
2098        else
2099            delete config.author;
2100    }
2101}
2102
2103/**
2104 * Toggles fullscreen mode.
2105 * @private
2106 */
2107function toggleFullscreen() {
2108    if (loaded && !error) {
2109        if (!fullscreenActive) {
2110            try {
2111                if (container.requestFullscreen) {
2112                    container.requestFullscreen();
2113                } else if (container.mozRequestFullScreen) {
2114                    container.mozRequestFullScreen();
2115                } else if (container.msRequestFullscreen) {
2116                    container.msRequestFullscreen();
2117                } else {
2118                    container.webkitRequestFullScreen();
2119                }
2120            } catch(event) {
2121                // Fullscreen doesn't work
2122            }
2123        } else {
2124            if (document.exitFullscreen) {
2125                document.exitFullscreen();
2126            } else if (document.mozCancelFullScreen) {
2127                document.mozCancelFullScreen();
2128            } else if (document.webkitCancelFullScreen) {
2129                document.webkitCancelFullScreen();
2130            } else if (document.msExitFullscreen) {
2131                document.msExitFullscreen();
2132            }
2133        }
2134    }
2135}
2136
2137/**
2138 * Event handler for fullscreen changes.
2139 * @private
2140 */
2141function onFullScreenChange(resize) {
2142    if (document.fullscreenElement || document.fullscreen || document.mozFullScreen || document.webkitIsFullScreen || document.msFullscreenElement) {
2143        controls.fullscreen.classList.add('pnlm-fullscreen-toggle-button-active');
2144        fullscreenActive = true;
2145    } else {
2146        controls.fullscreen.classList.remove('pnlm-fullscreen-toggle-button-active');
2147        fullscreenActive = false;
2148    }
2149    if (resize !== 'resize')
2150        fireEvent('fullscreenchange', fullscreenActive);
2151    // Resize renderer (deal with browser quirks and fixes #155)
2152    renderer.resize();
2153    setHfov(config.hfov);
2154    animateInit();
2155}
2156
2157/**
2158 * Increases panorama zoom. For use with zoom button.
2159 * @private
2160 */
2161function zoomIn() {
2162    if (loaded) {
2163        setHfov(config.hfov - 5);
2164        animateInit();
2165    }
2166}
2167
2168/**
2169 * Decreases panorama zoom. For use with zoom button.
2170 * @private
2171 */
2172function zoomOut() {
2173    if (loaded) {
2174        setHfov(config.hfov + 5);
2175        animateInit();
2176    }
2177}
2178
2179/**
2180 * Clamps horzontal field of view to viewer's limits.
2181 * @private
2182 * @param {number} hfov - Input horizontal field of view (in degrees)
2183 * @return {number} - Clamped horizontal field of view (in degrees)
2184 */
2185function constrainHfov(hfov) {
2186    // Keep field of view within bounds
2187    var minHfov = config.minHfov;
2188    if (config.type == 'multires' && renderer && !config.multiResMinHfov) {
2189        minHfov = Math.min(minHfov, renderer.getCanvas().width / (config.multiRes.cubeResolution / 90 * 0.9));
2190    }
2191    if (minHfov > config.maxHfov) {
2192        // Don't change view if bounds don't make sense
2193        console.log('HFOV bounds do not make sense (minHfov > maxHfov).');
2194        return config.hfov;
2195    }
2196    var newHfov = config.hfov;
2197    if (hfov < minHfov) {
2198        newHfov = minHfov;
2199    } else if (hfov > config.maxHfov) {
2200        newHfov = config.maxHfov;
2201    } else {
2202        newHfov = hfov;
2203    }
2204    // Optionally avoid showing background (empty space) on top or bottom by adapting newHfov
2205    if (config.avoidShowingBackground && renderer) {
2206        var canvas = renderer.getCanvas();
2207        newHfov = Math.min(newHfov,
2208                           Math.atan(Math.tan((config.maxPitch - config.minPitch) / 360 * Math.PI) /
2209                                     canvas.height * canvas.width) * 360 / Math.PI);
2210    }
2211    return newHfov;
2212}
2213
2214/**
2215 * Sets viewer's horizontal field of view.
2216 * @private
2217 * @param {number} hfov - Desired horizontal field of view in degrees.
2218 */
2219function setHfov(hfov) {
2220    config.hfov = constrainHfov(hfov);
2221    fireEvent('zoomchange', config.hfov);
2222}
2223
2224/**
2225 * Stops auto rotation and animated moves.
2226 * @private
2227 */
2228function stopAnimation() {
2229    animatedMove = {};
2230    autoRotateSpeed = config.autoRotate ? config.autoRotate : autoRotateSpeed;
2231    config.autoRotate = false;
2232}
2233
2234/**
2235 * Loads panorama.
2236 * @private
2237 */
2238function load() {
2239    // Since WebGL error handling is very general, first we clear any error box
2240    // since it is a new scene and the error from previous maybe because of lacking
2241    // memory etc and not because of a lack of WebGL support etc
2242    clearError();
2243    loaded = false;
2244
2245    controls.load.style.display = 'none';
2246    infoDisplay.load.box.style.display = 'inline';
2247    init();
2248}
2249
2250/**
2251 * Loads scene.
2252 * @private
2253 * @param {string} sceneId - Identifier of scene configuration to merge in.
2254 * @param {number} targetPitch - Pitch viewer should be centered on once scene loads.
2255 * @param {number} targetYaw - Yaw viewer should be centered on once scene loads.
2256 * @param {number} targetHfov - HFOV viewer should use once scene loads.
2257 * @param {boolean} [fadeDone] - If `true`, fade setup is skipped.
2258 */
2259function loadScene(sceneId, targetPitch, targetYaw, targetHfov, fadeDone) {
2260    if (!loaded)
2261        fadeDone = true;    // Don't try to fade when there isn't a scene loaded
2262    loaded = false;
2263    animatedMove = {};
2264    
2265    // Set up fade if specified
2266    var fadeImg, workingPitch, workingYaw, workingHfov;
2267    if (config.sceneFadeDuration && !fadeDone) {
2268        var data = renderer.render(config.pitch * Math.PI / 180, config.yaw * Math.PI / 180, config.hfov * Math.PI / 180, {returnImage: true});
2269        if (data !== undefined) {
2270            fadeImg = new Image();
2271            fadeImg.className = 'pnlm-fade-img';
2272            fadeImg.style.transition = 'opacity ' + (config.sceneFadeDuration / 1000) + 's';
2273            fadeImg.style.width = '100%';
2274            fadeImg.style.height = '100%';
2275            fadeImg.onload = function() {
2276                loadScene(sceneId, targetPitch, targetYaw, targetHfov, true);
2277            };
2278            fadeImg.src = data;
2279            renderContainer.appendChild(fadeImg);
2280            renderer.fadeImg = fadeImg;
2281            return;
2282        }
2283    }
2284    
2285    // Set new pointing
2286    if (targetPitch === 'same') {
2287        workingPitch = config.pitch;
2288    } else {
2289        workingPitch = targetPitch;
2290    }
2291    if (targetYaw === 'same') {
2292        workingYaw = config.yaw;
2293    } else if (targetYaw === 'sameAzimuth') {
2294        workingYaw = config.yaw + (config.northOffset || 0) - (initialConfig.scenes[sceneId].northOffset || 0);
2295    } else {
2296        workingYaw = targetYaw;
2297    }
2298    if (targetHfov === 'same') {
2299        workingHfov = config.hfov;
2300    } else {
2301        workingHfov = targetHfov;
2302    }
2303    
2304    // Destroy hot spots from previous scene
2305    destroyHotSpots();
2306    
2307    // Create the new config for the scene
2308    mergeConfig(sceneId);
2309
2310    // Stop motion
2311    speed.yaw = speed.pitch = speed.hfov = 0;
2312
2313    // Reload scene
2314    processOptions();
2315    if (workingPitch !== undefined) {
2316        config.pitch = workingPitch;
2317    }
2318    if (workingYaw !== undefined) {
2319        config.yaw = workingYaw;
2320    }
2321    if (workingHfov !== undefined) {
2322        config.hfov = workingHfov;
2323    }
2324    fireEvent('scenechange', sceneId);
2325    load();
2326}
2327
2328/**
2329 * Stop using device orientation.
2330 * @private
2331 */
2332function stopOrientation() {
2333    window.removeEventListener('deviceorientation', orientationListener);
2334    controls.orientation.classList.remove('pnlm-orientation-button-active');
2335    orientation = false;
2336}
2337
2338/**
2339 * Start using device orientation.
2340 * @private
2341 */
2342function startOrientation() {
2343    if (typeof DeviceMotionEvent.requestPermission === 'function') {
2344        DeviceOrientationEvent.requestPermission().then(function(response) {
2345            if (response == 'granted') {
2346                orientation = 1;
2347                window.addEventListener('deviceorientation', orientationListener);
2348                controls.orientation.classList.add('pnlm-orientation-button-active');
2349            }
2350        });
2351    } else {
2352        orientation = 1;
2353        window.addEventListener('deviceorientation', orientationListener);
2354        controls.orientation.classList.add('pnlm-orientation-button-active');
2355    }
2356}
2357
2358/**
2359 * Escapes HTML string (to mitigate possible DOM XSS attacks).
2360 * @private
2361 * @param {string} s - String to escape
2362 * @returns {string} Escaped string
2363 */
2364function escapeHTML(s) {
2365    if (!initialConfig.escapeHTML)
2366        return String(s).split('\n').join('<br>');
2367    return String(s).split(/&/g).join('&amp;')
2368        .split('"').join('&quot;')
2369        .split("'").join('&#39;')
2370        .split('<').join('&lt;')
2371        .split('>').join('&gt;')
2372        .split('/').join('&#x2f;')
2373        .split('\n').join('<br>');  // Allow line breaks
2374}
2375
2376/**
2377 * Removes possibility of XSS attacks with URLs.
2378 * The URL cannot be of protocol 'javascript'.
2379 * @private
2380 * @param {string} url - URL to sanitize
2381 * @param {boolean} href - True if URL is for link (blocks data URIs)
2382 * @returns {string} Sanitized URL
2383 */
2384function sanitizeURL(url, href) {
2385    try {
2386        var decoded_url = decodeURIComponent(unescape(url)).replace(/[^\w:]/g, '').toLowerCase();
2387    } catch (e) {
2388        return 'about:blank';
2389    }
2390    if (decoded_url.indexOf('javascript:') === 0 ||
2391        decoded_url.indexOf('vbscript:') === 0) {
2392        console.log('Script URL removed.');
2393        return 'about:blank';
2394    }
2395    if (href && decoded_url.indexOf('data:') === 0) {
2396        console.log('Data URI removed from link.');
2397        return 'about:blank';
2398    }
2399    return url;
2400}
2401
2402/**
2403 * Unescapes HTML entities.
2404 * Copied from Marked.js 0.7.0.
2405 * @private
2406 * @param {string} url - URL to sanitize
2407 * @param {boolean} href - True if URL is for link (blocks data URIs)
2408 * @returns {string} Sanitized URL
2409 */
2410function unescape(html) {
2411    // Explicitly match decimal, hex, and named HTML entities
2412    return html.replace(/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, function(_, n) {
2413        n = n.toLowerCase();
2414        if (n === 'colon') return ':';
2415        if (n.charAt(0) === '#') {
2416            return n.charAt(1) === 'x'
2417                ? String.fromCharCode(parseInt(n.substring(2), 16))
2418                : String.fromCharCode(+n.substring(1));
2419        }
2420        return '';
2421    });
2422}
2423
2424/**
2425 * Removes possibility of XSS atacks with URLs for CSS.
2426 * The URL will be sanitized with `sanitizeURL()` and single quotes
2427 * and double quotes escaped.
2428 * @private
2429 * @param {string} url - URL to sanitize
2430 * @returns {string} Sanitized URL
2431 */
2432function sanitizeURLForCss(url) {
2433    return sanitizeURL(url)
2434        .replace(/"/g, '%22')
2435        .replace(/'/g, '%27');
2436}
2437
2438/**
2439 * Checks whether or not a panorama is loaded.
2440 * @memberof Viewer
2441 * @instance
2442 * @returns {boolean} `true` if a panorama is loaded, else `false`
2443 */
2444this.isLoaded = function() {
2445    return Boolean(loaded);
2446};
2447
2448/**
2449 * Returns the pitch of the center of the view.
2450 * @memberof Viewer
2451 * @instance
2452 * @returns {number} Pitch in degrees
2453 */
2454this.getPitch = function() {
2455    return config.pitch;
2456};
2457
2458/**
2459 * Sets the pitch of the center of the view.
2460 * @memberof Viewer
2461 * @instance
2462 * @param {number} pitch - Pitch in degrees
2463 * @param {boolean|number} [animated=1000] - Animation duration in milliseconds or false for no animation
2464 * @param {function} [callback] - Function to call when animation finishes
2465 * @param {object} [callbackArgs] - Arguments to pass to callback function
2466 * @returns {Viewer} `this`
2467 */
2468this.setPitch = function(pitch, animated, callback, callbackArgs) {
2469    latestInteraction = Date.now();
2470    if (Math.abs(pitch - config.pitch) <= eps) {
2471        if (typeof callback == 'function')
2472            callback(callbackArgs);
2473        return this;
2474    }
2475    animated = animated == undefined ? 1000: Number(animated);
2476    if (animated) {
2477        animatedMove.pitch = {
2478            'startTime': Date.now(),
2479            'startPosition': config.pitch,
2480            'endPosition': pitch,
2481            'duration': animated
2482        };
2483        if (typeof callback == 'function')
2484            setTimeout(function(){callback(callbackArgs);}, animated);
2485    } else {
2486        config.pitch = pitch;
2487    }
2488    animateInit();
2489    return this;
2490};
2491
2492/**
2493 * Returns the minimum and maximum allowed pitches (in degrees).
2494 * @memberof Viewer
2495 * @instance
2496 * @returns {number[]} [minimum pitch, maximum pitch]
2497 */
2498this.getPitchBounds = function() {
2499    return [config.minPitch, config.maxPitch];
2500};
2501
2502/**
2503 * Set the minimum and maximum allowed pitches (in degrees).
2504 * @memberof Viewer
2505 * @instance
2506 * @param {number[]} bounds - [minimum pitch, maximum pitch]
2507 * @returns {Viewer} `this`
2508 */
2509this.setPitchBounds = function(bounds) {
2510    config.minPitch = Math.max(-90, Math.min(bounds[0], 90));
2511    config.maxPitch = Math.max(-90, Math.min(bounds[1], 90));
2512    return this;
2513};
2514
2515/**
2516 * Returns the yaw of the center of the view.
2517 * @memberof Viewer
2518 * @instance
2519 * @returns {number} Yaw in degrees
2520 */
2521this.getYaw = function() {
2522    return (config.yaw + 540) % 360 - 180;
2523};
2524
2525/**
2526 * Sets the yaw of the center of the view.
2527 * @memberof Viewer
2528 * @instance
2529 * @param {number} yaw - Yaw in degrees [-180, 180]
2530 * @param {boolean|number} [animated=1000] - Animation duration in milliseconds or false for no animation
2531 * @param {function} [callback] - Function to call when animation finishes
2532 * @param {object} [callbackArgs] - Arguments to pass to callback function
2533 * @returns {Viewer} `this`
2534 */
2535this.setYaw = function(yaw, animated, callback, callbackArgs) {
2536    latestInteraction = Date.now();
2537    if (Math.abs(yaw - config.yaw) <= eps) {
2538        if (typeof callback == 'function')
2539            callback(callbackArgs);
2540        return this;
2541    }
2542    animated = animated == undefined ? 1000: Number(animated);
2543    yaw = ((yaw + 180) % 360) - 180; // Keep in bounds
2544    if (animated) {
2545        // Animate in shortest direction
2546        if (config.yaw - yaw > 180)
2547            yaw += 360;
2548        else if (yaw - config.yaw > 180)
2549            yaw -= 360;
2550
2551        animatedMove.yaw = {
2552            'startTime': Date.now(),
2553            'startPosition': config.yaw,
2554            'endPosition': yaw,
2555            'duration': animated
2556        };
2557        if (typeof callback == 'function')
2558            setTimeout(function(){callback(callbackArgs);}, animated);
2559    } else {
2560        config.yaw = yaw;
2561    }
2562    animateInit();
2563    return this;
2564};
2565
2566/**
2567 * Returns the minimum and maximum allowed pitches (in degrees).
2568 * @memberof Viewer
2569 * @instance
2570 * @returns {number[]} [yaw pitch, maximum yaw]
2571 */
2572this.getYawBounds = function() {
2573    return [config.minYaw, config.maxYaw];
2574};
2575
2576/**
2577 * Set the minimum and maximum allowed yaws (in degrees [-360, 360]).
2578 * @memberof Viewer
2579 * @instance
2580 * @param {number[]} bounds - [minimum yaw, maximum yaw]
2581 * @returns {Viewer} `this`
2582 */
2583this.setYawBounds = function(bounds) {
2584    config.minYaw = Math.max(-360, Math.min(bounds[0], 360));
2585    config.maxYaw = Math.max(-360, Math.min(bounds[1], 360));
2586    return this;
2587};
2588
2589/**
2590 * Returns the horizontal field of view.
2591 * @memberof Viewer
2592 * @instance
2593 * @returns {number} Horizontal field of view in degrees
2594 */
2595this.getHfov = function() {
2596    return config.hfov;
2597};
2598
2599/**
2600 * Sets the horizontal field of view.
2601 * @memberof Viewer
2602 * @instance
2603 * @param {number} hfov - Horizontal field of view in degrees
2604 * @param {boolean|number} [animated=1000] - Animation duration in milliseconds or false for no animation
2605 * @param {function} [callback] - Function to call when animation finishes
2606 * @param {object} [callbackArgs] - Arguments to pass to callback function
2607 * @returns {Viewer} `this`
2608 */
2609this.setHfov = function(hfov, animated, callback, callbackArgs) {
2610    latestInteraction = Date.now();
2611    if (Math.abs(hfov - config.hfov) <= eps) {
2612        if (typeof callback == 'function')
2613            callback(callbackArgs);
2614        return this;
2615    }
2616    animated = animated == undefined ? 1000: Number(animated);
2617    if (animated) {
2618        animatedMove.hfov = {
2619            'startTime': Date.now(),
2620            'startPosition': config.hfov,
2621            'endPosition': constrainHfov(hfov),
2622            'duration': animated
2623        };
2624        if (typeof callback == 'function')
2625            setTimeout(function(){callback(callbackArgs);}, animated);
2626    } else {
2627        setHfov(hfov);
2628    }
2629    animateInit();
2630    return this;
2631};
2632
2633/**
2634 * Returns the minimum and maximum allowed horizontal fields of view
2635 * (in degrees).
2636 * @memberof Viewer
2637 * @instance
2638 * @returns {number[]} [minimum hfov, maximum hfov]
2639 */
2640this.getHfovBounds = function() {
2641    return [config.minHfov, config.maxHfov];
2642};
2643
2644/**
2645 * Set the minimum and maximum allowed horizontal fields of view (in degrees).
2646 * @memberof Viewer
2647 * @instance
2648 * @param {number[]} bounds - [minimum hfov, maximum hfov]
2649 * @returns {Viewer} `this`
2650 */
2651this.setHfovBounds = function(bounds) {
2652    config.minHfov = Math.max(0, bounds[0]);
2653    config.maxHfov = Math.max(0, bounds[1]);
2654    return this;
2655};
2656
2657/**
2658 * Set a new view. Any parameters not specified remain the same.
2659 * @memberof Viewer
2660 * @instance
2661 * @param {number} [pitch] - Target pitch
2662 * @param {number} [yaw] - Target yaw
2663 * @param {number} [hfov] - Target hfov
2664 * @param {boolean|number} [animated=1000] - Animation duration in milliseconds or false for no animation
2665 * @param {function} [callback] - Function to call when animation finishes
2666 * @param {object} [callbackArgs] - Arguments to pass to callback function
2667 * @returns {Viewer} `this`
2668 */
2669this.lookAt = function(pitch, yaw, hfov, animated, callback, callbackArgs) {
2670    animated = animated == undefined ? 1000: Number(animated);
2671    if (pitch !== undefined && Math.abs(pitch - config.pitch) > eps) {
2672        this.setPitch(pitch, animated, callback, callbackArgs);
2673        callback = undefined;
2674    }
2675    if (yaw !== undefined && Math.abs(yaw - config.yaw) > eps) {
2676        this.setYaw(yaw, animated, callback, callbackArgs);
2677        callback = undefined;
2678    }
2679    if (hfov !== undefined && Math.abs(hfov - config.hfov) > eps) {
2680        this.setHfov(hfov, animated, callback, callbackArgs);
2681        callback = undefined;
2682    }
2683    if (typeof callback == 'function')
2684        callback(callbackArgs);
2685    return this;
2686};
2687
2688/**
2689 * Returns the panorama's north offset.
2690 * @memberof Viewer
2691 * @instance
2692 * @returns {number} North offset in degrees
2693 */
2694this.getNorthOffset = function() {
2695    return config.northOffset;
2696};
2697
2698/**
2699 * Sets the panorama's north offset.
2700 * @memberof Viewer
2701 * @instance
2702 * @param {number} heading - North offset in degrees
2703 * @returns {Viewer} `this`
2704 */
2705this.setNorthOffset = function(heading) {
2706    config.northOffset = Math.min(360, Math.max(0, heading));
2707    animateInit();
2708    return this;
2709};
2710
2711/**
2712 * Returns the panorama's horizon roll.
2713 * @memberof Viewer
2714 * @instance
2715 * @returns {number} Horizon roll in degrees
2716 */
2717this.getHorizonRoll = function() {
2718    return config.horizonRoll;
2719};
2720
2721/**
2722 * Sets the panorama's horizon roll.
2723 * @memberof Viewer
2724 * @instance
2725 * @param {number} roll - Horizon roll in degrees [-90, 90]
2726 * @returns {Viewer} `this`
2727 */
2728this.setHorizonRoll = function(roll) {
2729    config.horizonRoll = Math.min(90, Math.max(-90, roll));
2730    renderer.setPose(config.horizonPitch * Math.PI / 180, config.horizonRoll * Math.PI / 180);
2731    animateInit();
2732    return this;
2733};
2734
2735/**
2736 * Returns the panorama's horizon pitch.
2737 * @memberof Viewer
2738 * @instance
2739 * @returns {number} Horizon pitch in degrees
2740 */
2741this.getHorizonPitch = function() {
2742    return config.horizonPitch;
2743};
2744
2745/**
2746 * Sets the panorama's horizon pitch.
2747 * @memberof Viewer
2748 * @instance
2749 * @param {number} pitch - Horizon pitch in degrees [-90, 90]
2750 * @returns {Viewer} `this`
2751 */
2752this.setHorizonPitch = function(pitch) {
2753    config.horizonPitch = Math.min(90, Math.max(-90, pitch));
2754    renderer.setPose(config.horizonPitch * Math.PI / 180, config.horizonRoll * Math.PI / 180);
2755    animateInit();
2756    return this;
2757};
2758
2759/**
2760 * Start auto rotation.
2761 *
2762 * Before starting rotation, the viewer is panned to `pitch`.
2763 * @memberof Viewer
2764 * @instance
2765 * @param {number} [speed] - Auto rotation speed / direction. If not specified, previous value is used.
2766 * @param {number} [pitch] - The pitch to rotate at. If not specified, inital pitch is used.
2767 * @returns {Viewer} `this`
2768 */
2769this.startAutoRotate = function(speed, pitch) {
2770    speed = speed || autoRotateSpeed || 1;
2771    pitch = pitch === undefined ? origPitch : pitch;
2772    config.autoRotate = speed;
2773    _this.lookAt(pitch, undefined, origHfov, 3000);
2774    animateInit();
2775    return this;
2776};
2777
2778/**
2779 * Stop auto rotation.
2780 * @memberof Viewer
2781 * @instance
2782 * @returns {Viewer} `this`
2783 */
2784this.stopAutoRotate = function() {
2785    autoRotateSpeed = config.autoRotate ? config.autoRotate : autoRotateSpeed;
2786    config.autoRotate = false;
2787    config.autoRotateInactivityDelay = -1;
2788    return this;
2789};
2790
2791/**
2792 * Stops all movement.
2793 * @memberof Viewer
2794 * @instance
2795 */
2796this.stopMovement = function() {
2797    stopAnimation();
2798    speed = {'yaw': 0, 'pitch': 0, 'hfov': 0};
2799};
2800
2801/**
2802 * Returns the panorama renderer.
2803 * @memberof Viewer
2804 * @instance
2805 * @returns {Renderer}
2806 */
2807this.getRenderer = function() {
2808    return renderer;
2809};
2810
2811/**
2812 * Sets update flag for dynamic content.
2813 * @memberof Viewer
2814 * @instance
2815 * @param {boolean} bool - Whether or not viewer should update even when still
2816 * @returns {Viewer} `this`
2817 */
2818this.setUpdate = function(bool) {
2819    update = bool === true;
2820    if (renderer === undefined)
2821        onImageLoad();
2822    else
2823        animateInit();
2824    return this;
2825};
2826
2827/**
2828 * Calculate panorama pitch and yaw from location of mouse event.
2829 * @memberof Viewer
2830 * @instance
2831 * @param {MouseEvent} event - Document mouse down event.
2832 * @returns {number[]} [pitch, yaw]
2833 */
2834this.mouseEventToCoords = function(event) {
2835    return mouseEventToCoords(event);
2836};
2837
2838/**
2839 * Change scene being viewed.
2840 * @memberof Viewer
2841 * @instance
2842 * @param {string}
2842 sceneId - Identifier of scene to switch to.
2843 * @param {number} [pitch] - Pitch to use with new scene
2844 * @param {number} [yaw] - Yaw to use with new scene
2845 * @param {number} [hfov] - HFOV to use with new scene
2846 * @returns {Viewer} `this`
2847 */
2848this.loadScene = function(sceneId, pitch, yaw, hfov) {
2849    if (loaded !== false)
2850        loadScene(sceneId, pitch, yaw, hfov);
2851    return this;
2852};
2853
2854/**
2855 * Get ID of current scene.
2856 * @memberof Viewer
2857 * @instance
2858 * @returns {string} ID of current scene
2859 */
2860this.getScene = function() {
2861    return config.scene;
2862};
2863
2864/**
2865 * Add a new scene.
2866 * @memberof Viewer
2867 * @instance
2868 * @param {string} sceneId - The ID of the new scene
2869 * @param {string} config - The configuration of the new scene
2870 * @returns {Viewer} `this`
2871 */
2872this.addScene = function(sceneId, config) {
2873    initialConfig.scenes[sceneId] = config;
2874    return this;
2875};
2876
2877/**
2878 * Remove a scene.
2879 * @memberof Viewer
2880 * @instance
2881 * @param {string} sceneId - The ID of the scene
2882 * @returns {boolean} False if the scene is the current scene or if the scene doesn't exists, else true
2883 */
2884this.removeScene = function(sceneId) {
2885    if (config.scene === sceneId || !initialConfig.scenes.hasOwnProperty(sceneId))
2886        return false;
2887    delete initialConfig.scenes[sceneId];
2888    return true;
2889};
2890
2891/**
2892 * Toggle fullscreen.
2893 * @memberof Viewer
2894 * @instance
2895 * @returns {Viewer} `this`
2896 */
2897this.toggleFullscreen = function() {
2898    toggleFullscreen();
2899    return this;
2900};
2901
2902/**
2903 * Get configuration of current scene.
2904 * @memberof Viewer
2905 * @instance
2906 * @returns {Object} Configuration of current scene
2907 */
2908this.getConfig = function() {
2909    return config;
2910};
2911
2912/**
2913 * Get viewer's container element.
2914 * @memberof Viewer
2915 * @instance
2916 * @returns {HTMLElement} Container `div` element
2917 */
2918this.getContainer = function() {
2919    return container;
2920};
2921
2922/**
2923 * Add a new hot spot.
2924 * @memberof Viewer
2925 * @instance
2926 * @param {Object} hs - The configuration for the hot spot
2927 * @param {string} [sceneId] - Adds hot spot to specified scene if provided, else to current scene
2928 * @returns {Viewer} `this`
2929 * @throws Throws an error if the scene ID is provided but invalid
2930 */
2931this.addHotSpot = function(hs, sceneId) {
2932    if (sceneId === undefined && config.scene === undefined) {
2933        // Not a tour
2934        config.hotSpots.push(hs);
2935    } else {
2936        // Tour
2937        var id = sceneId !== undefined ? sceneId : config.scene;
2938        if (initialConfig.scenes.hasOwnProperty(id)) {
2939            if (!initialConfig.scenes[id].hasOwnProperty('hotSpots')) {
2940                initialConfig.scenes[id].hotSpots = []; // Create hot spots array if needed
2941                if (id == config.scene)
2942                    config.hotSpots = initialConfig.scenes[id].hotSpots;    // Link to current config
2943            }
2944            initialConfig.scenes[id].hotSpots.push(hs); // Add hot spot to config
2945        } else {
2946            throw 'Invalid scene ID!';
2947        }
2948    }
2949    if (sceneId === undefined || config.scene == sceneId) {
2950        // Add to current scene
2951        createHotSpot(hs);
2952        if (loaded)
2953            renderHotSpot(hs);
2954    }
2955    return this;
2956};
2957
2958/**
2959 * Remove a hot spot.
2960 * @memberof Viewer
2961 * @instance
2962 * @param {string} hotSpotId - The ID of the hot spot
2963 * @param {string} [sceneId] - Removes hot spot from specified scene if provided, else from current scene
2964 * @returns {boolean} True if deletion is successful, else false
2965 */
2966this.removeHotSpot = function(hotSpotId, sceneId) {
2967    if (sceneId === undefined || config.scene == sceneId) {
2968        if (!config.hotSpots)
2969            return false;
2970        for (var i = 0; i < config.hotSpots.length; i++) {
2971            if (config.hotSpots[i].hasOwnProperty('id') &&
2972                config.hotSpots[i].id === hotSpotId) {
2973                // Delete hot spot DOM elements
2974                var current = config.hotSpots[i].div;
2975                while (current.parentNode != renderContainer)
2976                    current = current.parentNode;
2977                renderContainer.removeChild(current);
2978                delete config.hotSpots[i].div;
2979                // Remove hot spot from configuration
2980                config.hotSpots.splice(i, 1);
2981                return true;
2982            }
2983        }
2984    } else {
2985        if (initialConfig.scenes.hasOwnProperty(sceneId)) {
2986            if (!initialConfig.scenes[sceneId].hasOwnProperty('hotSpots'))
2987                return false;
vendor: 5,046 bytes, lines 2988-3156
2988            for (var j = 0; j < initialConfig.scenes[sceneId].hotSpots.length; j++) {
2989                if (initialConfig.scenes[sceneId].hotSpots[j].hasOwnProperty('id') &&
2990                    initialConfig.scenes[sceneId].hotSpots[j].id === hotSpotId) {
2991                    // Remove hot spot from configuration
2992                    initialConfig.scenes[sceneId].hotSpots.splice(j, 1);
2993                    return true;
2994                }
2995            }
2996        } else {
2997            return false;
2998        }
2999    }
3000};
3001
3002/**
3003 * This method should be called if the viewer's container is resized.
3004 * @memberof Viewer
3005 * @instance
3006 */
3007this.resize = function() {
3008    if (renderer)
3009        onDocumentResize();
3010};
3011
3012/**
3013 * Check if a panorama is loaded.
3014 * @memberof Viewer
3015 * @instance
3016 * @returns {boolean} True if a panorama is loaded, else false
3017 */
3018this.isLoaded = function() {
3019    return loaded;
3020};
3021
3022/**
3023 * Check if device orientation control is supported.
3024 * @memberof Viewer
3025 * @instance
3026 * @returns {boolean} True if supported, else false
3027 */
3028this.isOrientationSupported = function() {
3029    return orientationSupport || false;
3030};
3031
3032/**
3033 * Stop using device orientation.
3034 * @memberof Viewer
3035 * @instance
3036 */
3037this.stopOrientation = function() {
3038    stopOrientation();
3039};
3040
3041/**
3042 * Start using device orientation (does nothing if not supported).
3043 * @memberof Viewer
3044 * @instance
3045 */
3046this.startOrientation = function() {
3047    if (orientationSupport)
3048        startOrientation();
3049};
3050
3051/**
3052 * Check if device orientation control is currently activated.
3053 * @memberof Viewer
3054 * @instance
3055 * @returns {boolean} True if active, else false
3056 */
3057this.isOrientationActive = function() {
3058    return Boolean(orientation);
3059};
3060
3061/**
3062 * Subscribe listener to specified event.
3063 * @memberof Viewer
3064 * @instance
3065 * @param {string} type - Type of event to subscribe to.
3066 * @param {Function} listener - Listener function to subscribe to event.
3067 * @returns {Viewer} `this`
3068 */
3069this.on = function(type, listener) {
3070    externalEventListeners[type] = externalEventListeners[type] || [];
3071    externalEventListeners[type].push(listener);
3072    return this;
3073};
3074
3075/**
3076 * Remove an event listener (or listeners).
3077 * @memberof Viewer
3078 * @param {string} [type] - Type of event to remove listeners from. If not specified, all listeners are removed.
3079 * @param {Function} [listener] - Listener function to remove. If not specified, all listeners of specified type are removed.
3080 * @returns {Viewer} `this`
3081 */
3082this.off = function(type, listener) {
3083    if (!type) {
3084        // Remove all listeners if type isn't specified
3085        externalEventListeners = {};
3086        return this;
3087    }
3088    if (listener) {
3089        var i = externalEventListeners[type].indexOf(listener);
3090        if (i >= 0) {
3091            // Remove listener if found
3092            externalEventListeners[type].splice(i, 1);
3093        }
3094        if (externalEventListeners[type].length == 0) {
3095            // Remove category if empty
3096            delete externalEventListeners[type];
3097        }
3098    } else {
3099        // Remove category of listeners if listener isn't specified
3100        delete externalEventListeners[type];
3101    }
3102    return this;
3103};
3104
3105/**
3106 * Fire listeners attached to specified event.
3107 * @private
3108 * @param {string} [type] - Type of event to fire listeners for.
3109 */
3110function fireEvent(type) {
3111    if (type in externalEventListeners) {
3112        // Reverse iteration is useful, if event listener is removed inside its definition
3113        for (var i = externalEventListeners[type].length; i > 0; i--) {
3114            externalEventListeners[type][externalEventListeners[type].length - i].apply(null, [].slice.call(arguments, 1));
3115        }
3116    }
3117}
3118
3119/**
3120 * Destructor.
3121 * @instance
3122 * @memberof Viewer
3123 */
3124this.destroy = function() {
3125    destroyed = true;
3126    clearTimeout(autoRotateStart);
3127
3128    if (renderer)
3129        renderer.destroy();
3130    if (listenersAdded) {
3131        document.removeEventListener('mousemove', onDocumentMouseMove, false);
3132        document.removeEventListener('mouseup', onDocumentMouseUp, false);
3133        container.removeEventListener('mozfullscreenchange', onFullScreenChange, false);
3134        container.removeEventListener('webkitfullscreenchange', onFullScreenChange, false);
3135        container.removeEventListener('msfullscreenchange', onFullScreenChange, false);
3136        container.removeEventListener('fullscreenchange', onFullScreenChange, false);
3137        window.removeEventListener('resize', onDocumentResize, false);
3138        window.removeEventListener('orientationchange', onDocumentResize, false);
3139        container.removeEventListener('keydown', onDocumentKeyPress, false);
3140        container.removeEventListener('keyup', onDocumentKeyUp, false);
3141        container.removeEventListener('blur', clearKeys, false);
3142        document.removeEventListener('mouseleave', onDocumentMouseUp, false);
3143    }
3144    container.innerHTML = '';
3145    container.classList.remove('pnlm-container');
3146};
3147
3148}
3149
3150return {
3151    viewer: function(container, config) {
3152        return new Viewer(container, config);
3153    }
3154};
3155
3156})(window, document);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.