├── kaleidoscope.gif ├── .gitignore ├── src ├── image.js ├── main.js ├── utils.js ├── video.js ├── renderer.js ├── canvas.js ├── audio.js ├── three-sixty-viewer.js └── controls.js ├── rollup.config.js ├── test ├── controls.js ├── image.js ├── canvas.js ├── audio.js ├── three-sixty-viewer.js └── utils.js ├── examples ├── image.html ├── mp4.html ├── dash.html └── hls.html ├── index.html ├── package.json ├── karma.conf.js ├── README.md └── LICENSE /kaleidoscope.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thiagopnts/kaleidoscope/HEAD/kaleidoscope.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/kaleidoscope.js 2 | dist/kaleidoscope.iife.js 3 | node_modules/ 4 | .DS_Store 5 | *.mp4 6 | *.html 7 | -------------------------------------------------------------------------------- /src/image.js: -------------------------------------------------------------------------------- 1 | import ThreeSixtyViewer from './three-sixty-viewer'; 2 | 3 | export default class Image extends ThreeSixtyViewer { 4 | constructor(options) { 5 | super(options); 6 | } 7 | 8 | getElement() { 9 | if (this.source && this.source.tagName) 10 | return this.source; 11 | let image = document.createElement('img'); 12 | image.setAttribute('crossorigin', 'anonymous'); 13 | image.src = this.source; 14 | return image; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import utils from './utils' 2 | 3 | import Video from './video' 4 | import Image from './image' 5 | import Canvas from './canvas' 6 | import Audio from './audio' 7 | 8 | let video = (options) => { 9 | if (utils.shouldUseAudioDriver()) { 10 | return new Audio(options); 11 | } 12 | if (utils.shouldUseCanvasInBetween()) { 13 | return new Canvas(options); 14 | } 15 | return new Video(options); 16 | } 17 | 18 | export { 19 | video as Video, 20 | Image, 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export default { 2 | isiOS() { 3 | return /(ipad|iphone|ipod)/ig.test(navigator.userAgent); 4 | }, 5 | isEdge() { 6 | return /(Edge)/ig.test(navigator.userAgent); 7 | }, 8 | shouldUseAudioDriver() { 9 | let isOldiOSOnIphone = /iphone.*(7|8|9)_[0-9]/i.test(navigator.userAgent); 10 | let isWebView = /(iPhone|iPod).*AppleWebKit(?!.*Safari)/i.test(navigator.userAgent); 11 | return isOldiOSOnIphone || isWebView; 12 | }, 13 | shouldUseCanvasInBetween() { 14 | return /trident|edge/i.test(navigator.userAgent); 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /src/video.js: -------------------------------------------------------------------------------- 1 | import ThreeSixtyViewer from './three-sixty-viewer'; 2 | import THREE from 'threejs360'; 3 | 4 | export default class Video extends ThreeSixtyViewer { 5 | constructor(options) { 6 | super(options); 7 | } 8 | 9 | createTexture() { 10 | let texture = new THREE.VideoTexture(this.element); 11 | //TODO: we can pass all this info through the constructor 12 | texture.minFilter = THREE.LinearFilter; 13 | texture.magFilter = THREE.LinearFilter; 14 | texture.format = THREE.RGBFormat; 15 | texture.generateMipmaps = false; 16 | texture.needsUpdate = true; 17 | return texture; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import nodeResolve from 'rollup-plugin-node-resolve'; 4 | import strip from 'rollup-plugin-strip'; 5 | 6 | const name = 'Kaleidoscope'; 7 | const output = [ 8 | {file: 'dist/kaleidoscope.js', format: 'umd', name}, 9 | {file: 'dist/kaleidoscope.iife.js', format: 'iife', name}, 10 | ]; 11 | 12 | if (process.env.BABEL_ENV !== 'production') { 13 | output.push({file: 'dist/kaleidoscope.es.js', format: 'es'}) 14 | } 15 | 16 | export default { 17 | input: 'src/main.js', 18 | output, 19 | plugins: [ 20 | nodeResolve({ 21 | jsnext: true, 22 | browser: true, 23 | }), 24 | strip({debugger: true, sourceMap: false}), 25 | commonjs(), 26 | babel(), 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /test/controls.js: -------------------------------------------------------------------------------- 1 | import Controls from '../src/controls'; 2 | import Renderer from '../src/renderer'; 3 | 4 | let event = {clientX: 0, clientY: 0}; 5 | 6 | describe('Controls', () => { 7 | it('onDragStart', () => { 8 | let renderer = new Renderer({height: 50, width: 50}); 9 | let onDragStart = sinon.spy(); 10 | let controls = new Controls({onDragStart, renderer}); 11 | controls.onMouseDown(event); 12 | 13 | assert.ok(onDragStart.calledOnce); 14 | }); 15 | 16 | it('onDragStop', () => { 17 | let renderer = new Renderer({height: 50, width: 50}); 18 | let onDragStop = sinon.spy(); 19 | let controls = new Controls({onDragStop, renderer}); 20 | controls.onMouseDown(event); 21 | controls.onMouseUp(event); 22 | 23 | assert.ok(onDragStop.calledOnce); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /examples/image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kaleidoscope image example 6 | 7 | 8 | 9 |
10 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/image.js: -------------------------------------------------------------------------------- 1 | import Image from '../src/image'; 2 | 3 | describe('Image', () => { 4 | describe('source', () => { 5 | it('as string', () => { 6 | let viewer = new Image({source: 'foo.jpg'}); 7 | assert.equal(viewer.element.tagName, 'IMG'); 8 | assert.ok(viewer.element.src.endsWith('foo.jpg')); 9 | }); 10 | 11 | it('as element', () => { 12 | let image = document.createElement('img'); 13 | image.src = 'foo.jpg'; 14 | let viewer = new Image({source: image}); 15 | assert.equal(viewer.element.tagName, 'IMG'); 16 | assert.equal(viewer.element, image); 17 | assert.ok(viewer.element.src.endsWith('foo.jpg')); 18 | }); 19 | }); 20 | 21 | it('hides the rendered video on render', () => { 22 | let viewer = new Image({source: 'foo.mp4', container: document.createElement('div')}); 23 | viewer.render(); 24 | assert.equal(viewer.element.style.display, 'none'); 25 | viewer.destroy(); 26 | assert.equal(viewer.element.style.display, ''); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/canvas.js: -------------------------------------------------------------------------------- 1 | import Canvas from '../src/canvas'; 2 | 3 | describe('Canvas', () => { 4 | describe('source', () => { 5 | it('as string', () => { 6 | let viewer = new Canvas({source: 'foo.mp4'}); 7 | assert.equal(viewer.element.tagName, 'CANVAS'); 8 | assert.ok(viewer.video.src.endsWith('foo.mp4')); 9 | }); 10 | 11 | it('as element', () => { 12 | let video = document.createElement('video'); 13 | video.src = 'foo.mp4'; 14 | let viewer = new Canvas({source: video}); 15 | assert.equal(viewer.element.tagName, 'CANVAS'); 16 | assert.equal(viewer.video.tagName, 'VIDEO'); 17 | assert.equal(viewer.video, video); 18 | assert.ok(viewer.video.src.endsWith('foo.mp4')); 19 | }); 20 | }); 21 | 22 | it('hides the rendered video on render', () => { 23 | let viewer = new Canvas({source: 'foo.mp4', container: document.createElement('div')}); 24 | viewer.render(); 25 | assert.equal(viewer.video.style.display, 'none'); 26 | viewer.destroy(); 27 | assert.equal(viewer.video.style.display, ''); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /examples/mp4.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kaleidoscope mp4 video example 6 | 7 | 8 | 9 |

tap/click to page to start

10 |
11 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | import THREE from 'threejs360'; 2 | 3 | export default class Renderer { 4 | constructor(options) { 5 | Object.assign(this, options); 6 | this.renderer = new THREE.WebGLRenderer(); 7 | this.renderer.setClearColor(0x000000, 0); 8 | this.renderer.setSize(this.width, this.height); 9 | this.renderer.setPixelRatio(window.devicePixelRatio); 10 | this.el = this.renderer.domElement; 11 | } 12 | 13 | setTexture(texture) { 14 | this.texture = texture; 15 | this.mesh = this.createMesh(); 16 | } 17 | 18 | setSize({height, width}) { 19 | this.height = height; 20 | this.width = width; 21 | this.renderer.setSize(width, height); 22 | } 23 | 24 | createMesh() { 25 | this.material = new THREE.MeshBasicMaterial({map: this.texture}); 26 | this.geometry = new THREE.SphereGeometry(1, 50, 50); 27 | this.geometry.scale(-1, 1, 1); 28 | let mesh = new THREE.Mesh(this.geometry, this.material); 29 | return mesh; 30 | } 31 | 32 | destroy() { 33 | this.geometry.dispose(); 34 | this.material.dispose(); 35 | this.renderer.dispose(); 36 | } 37 | 38 | render(scene, camera, needsUpdate) { 39 | if (!needsUpdate) return; 40 | this.renderer.render(scene, camera); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/audio.js: -------------------------------------------------------------------------------- 1 | import Audio from '../src/audio'; 2 | 3 | describe('Audio', () => { 4 | describe('source', () => { 5 | it('as string', () => { 6 | let viewer = new Audio({source: 'foo.mp4'}); 7 | assert.equal(viewer.driver.tagName, 'AUDIO'); 8 | assert.equal(viewer.element.tagName, 'VIDEO'); 9 | assert.ok(viewer.element.src.endsWith('foo.mp4')); 10 | }); 11 | 12 | it('as element', () => { 13 | let audio = document.createElement('audio'); 14 | audio.src = 'foo.mp4'; 15 | let viewer = new Audio({source: audio}); 16 | assert.equal(viewer.driver, audio); 17 | assert.equal(viewer.driver.tagName, 'AUDIO'); 18 | assert.equal(viewer.element.tagName, 'VIDEO'); 19 | assert.ok(viewer.element.src.endsWith('foo.mp4')); 20 | }); 21 | }); 22 | 23 | it('hides the rendered element and the driver on render', () => { 24 | let viewer = new Audio({source: 'foo.mp4', container: document.createElement('div')}); 25 | viewer.render(); 26 | assert.equal(viewer.element.style.display, 'none'); 27 | assert.equal(viewer.driver.style.display, 'none'); 28 | viewer.destroy(); 29 | assert.equal(viewer.element.style.display, ''); 30 | assert.equal(viewer.driver.style.display, ''); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /examples/dash.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kaleidoscope dash video example 6 | 7 | 8 | 9 | 10 |

tap/click to page to start

11 |
12 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/hls.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kaleidoscope hls video example 6 | 7 | 8 | 9 | 10 |

tap/click to page to start

11 |
12 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/canvas.js: -------------------------------------------------------------------------------- 1 | import ThreeSixtyViewer from './three-sixty-viewer'; 2 | 3 | export default class Canvas extends ThreeSixtyViewer { 4 | constructor(options) { 5 | super(options); 6 | this.context = this.element.getContext('2d'); 7 | } 8 | 9 | play() { 10 | this.video.play && this.video.play(); 11 | } 12 | 13 | pause() { 14 | this.video.pause && this.video.pause(); 15 | } 16 | 17 | destroy() { 18 | this.video.style.display = ''; 19 | super.destroy(); 20 | } 21 | 22 | getElement() { 23 | this.video = super.getElement(); 24 | this.video.addEventListener('playing', this.startVideoLoop); 25 | this.video.addEventListener('pause', this.stopVideoLoop); 26 | this.video.addEventListener('ended', this.stopVideoLoop); 27 | let canvas = document.createElement('canvas'); 28 | canvas.height = this.video.videoHeight; 29 | canvas.width = this.video.videoWidth; 30 | return canvas; 31 | } 32 | 33 | render() { 34 | this.target.appendChild(this.renderer.el); 35 | this.video.style.display = 'none'; 36 | let loop = () => { 37 | this.animationFrameId = requestAnimationFrame(loop); 38 | let h = this.video.videoHeight; 39 | let w = this.video.videoWidth; 40 | if (this.element.height != h) { 41 | this.element.height = h; 42 | } 43 | if (this.element.width != w) { 44 | this.element.width = w; 45 | } 46 | this.context.clearRect(0, 0, w, h); 47 | this.context.drawImage(this.video, 0, 0, w, h); 48 | let cameraUpdated = this.controls.update(); 49 | this.renderer.render(this.scene, this.camera, this.needsUpdate || cameraUpdated); 50 | this.renderer.mesh.material.map.needsUpdate = true 51 | this.needsUpdate = false; 52 | }; 53 | this.startVideoLoop(); 54 | loop(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | index 6 | 7 | 8 | 9 | 10 | 11 | 39 | 40 | 41 |
42 |

tap/click to page to start

43 | 44 |
45 | 46 |
47 | 48 | 49 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /test/three-sixty-viewer.js: -------------------------------------------------------------------------------- 1 | import ThreeSixty from '../src/three-sixty-viewer'; 2 | 3 | describe('360 viewer', () => { 4 | 5 | it('has a default height/width and vertical panning', () => { 6 | let viewer = new ThreeSixty(); 7 | assert.isDefined(viewer.height); 8 | assert.isNumber(viewer.height); 9 | assert.isDefined(viewer.width); 10 | assert.isNumber(viewer.width); 11 | assert(viewer.verticalPanning); 12 | }); 13 | 14 | it('controls should start looking at 90 degrees', () => { 15 | let viewer = new ThreeSixty(); 16 | // theta values are in radians 17 | assert.equal(viewer.controls.theta.toFixed(2), 1.57); 18 | }); 19 | 20 | it('accepts custom initial view point', () => { 21 | let viewer = new ThreeSixty({initialYaw: 180}); 22 | 23 | assert.equal(viewer.controls.theta.toFixed(2), 3.14); 24 | }); 25 | 26 | it('moves back to the center', () => { 27 | let viewer = new ThreeSixty({initialYaw: 180}); 28 | viewer.controls.theta = 1.57; 29 | viewer.controls.phi = 0.78; 30 | viewer.centralize(); 31 | setTimeout(() => { 32 | assert.equal(viewer.controls.phi.toFixed(2), 0); 33 | assert.equal(viewer.controls.theta.toFixed(2), 3.14); 34 | }, 0); 35 | }); 36 | 37 | it('disables vertical panning', () => { 38 | let viewer = new ThreeSixty({verticalPanning: false}); 39 | viewer.controls.onMouseMove({clientX: 123, clientY: 123}); 40 | assert.equal(viewer.controls.phi, 0); 41 | assert.equal(viewer.controls.theta.toFixed(2), 1.57); 42 | }); 43 | 44 | it('creates an element with the given source and defaults to a video tag', () => { 45 | let viewer = new ThreeSixty({source: 'foo.mp4'}); 46 | assert.ok(viewer.element.src.endsWith('foo.mp4')); 47 | }); 48 | 49 | it('accepts a pre-cooked element to be used as a media source', () => { 50 | let video = document.createElement('video'); 51 | video.src = 'foo.mp4'; 52 | let viewer = new ThreeSixty({source: video}); 53 | assert.ok(viewer.element.src.endsWith('foo.mp4')); 54 | assert.equal(viewer.element, video); 55 | }); 56 | 57 | it('hides element on render', () => { 58 | let video = document.createElement('video'); 59 | video.src = 'foo.mp4'; 60 | let viewer = new ThreeSixty({source: video, container: document.createElement('div')}); 61 | viewer.render(); 62 | assert.equal(viewer.element.style.display, 'none'); 63 | viewer.destroy(); 64 | assert.equal(viewer.element.style.display, ''); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@thiagopnts/kaleidoscope", 3 | "version": "1.1.1", 4 | "description": "360º video/image viewer", 5 | "main": "dist/kaleidoscope.min.js", 6 | "module": "dist/kaleidoscope.es.js", 7 | "scripts": { 8 | "test": "karma start --single-run", 9 | "build": "rollup -c", 10 | "build-release": "BABEL_ENV=production rollup -c && cat dist/kaleidoscope.js | uglifyjs -m -c > dist/kaleidoscope.min.js && cat dist/kaleidoscope.iife.js | uglifyjs -m toplevel -c > dist/kaleidoscope.iife.min.js" 11 | }, 12 | "files": [ 13 | "package.json", 14 | "LICENSE", 15 | "README.md", 16 | "dist/kaleidoscope.es.js", 17 | "dist/kaleidoscope.iife.min.js", 18 | "dist/kaleidoscope.min.js", 19 | "src", 20 | "test" 21 | ], 22 | "babel": { 23 | "presets": [ 24 | [ 25 | "@babel/env", 26 | { 27 | "modules": false 28 | } 29 | ] 30 | ], 31 | "env": { 32 | "production": { 33 | "presets": [ 34 | "minify" 35 | ] 36 | } 37 | }, 38 | "plugins": [ 39 | "@babel/plugin-external-helpers" 40 | ] 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/thiagopnts/kaleidoscope.git" 45 | }, 46 | "keywords": [ 47 | "360", 48 | "video", 49 | "video360", 50 | "360video", 51 | "360image", 52 | "image360", 53 | "image" 54 | ], 55 | "author": "Thiago Pontes ", 56 | "license": "Apache-2.0", 57 | "bugs": { 58 | "url": "https://github.com/thiagopnts/kaleidoscope/issues" 59 | }, 60 | "homepage": "https://github.com/thiagopnts/kaleidoscope#readme", 61 | "devDependencies": { 62 | "@babel/core": "^7.1.5", 63 | "@babel/plugin-external-helpers": "^7.0.0", 64 | "@babel/preset-env": "^7.1.5", 65 | "babel-preset-minify": "^0.5.0", 66 | "chai": "^4.2.0", 67 | "jasmine-core": "^3.3.0", 68 | "karma": "^3.1.1", 69 | "karma-chai": "^0.1.0", 70 | "karma-chrome-launcher": "^2.2.0", 71 | "karma-jasmine": "^1.1.2", 72 | "karma-mocha": "^1.3.0", 73 | "karma-rollup-preprocessor": "^6.1.0", 74 | "karma-sinon": "^1.0.5", 75 | "mocha": "^5.2.0", 76 | "rollup": "^0.67.0", 77 | "rollup-plugin-babel": "^4.0.3", 78 | "rollup-plugin-commonjs": "^9.2.0", 79 | "rollup-plugin-node-resolve": "^3.4.0", 80 | "rollup-plugin-strip": "^1.2.0", 81 | "sinon": "^7.1.1", 82 | "uglify-js": "^3.4.9" 83 | }, 84 | "dependencies": { 85 | "threejs360": "^1.0.3" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var babel = require('rollup-plugin-babel'); 2 | var commonjs = require('rollup-plugin-commonjs'); 3 | var nodeResolve = require('rollup-plugin-node-resolve'); 4 | // Karma configuration 5 | // Generated on Wed Jul 27 2016 18:19:19 GMT-0400 (EDT) 6 | 7 | module.exports = function(config) { 8 | config.set({ 9 | 10 | // base path that will be used to resolve all patterns (eg. files, exclude) 11 | basePath: '', 12 | 13 | 14 | // frameworks to use 15 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 16 | frameworks: ['mocha', 'chai', 'sinon'], 17 | 18 | 19 | // list of files / patterns to load in the browser 20 | files: [ 21 | {pattern: 'src/*.js', included: false}, 22 | 'test/*.js' 23 | ], 24 | 25 | 26 | // list of files to exclude 27 | exclude: [ 28 | ], 29 | 30 | 31 | // preprocess matching files before serving them to the browser 32 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 33 | preprocessors: { 34 | 'test/*.js': ['rollup'], 35 | }, 36 | 37 | rollupPreprocessor: { 38 | // rollup settings. See Rollup documentation 39 | plugins: [ 40 | babel(), 41 | nodeResolve({ 42 | jsnext: true, 43 | browser: true, 44 | }), 45 | commonjs(), 46 | ], 47 | output: { 48 | format: 'cjs', 49 | sourcemap: 'inline' 50 | }, 51 | // will help to prevent conflicts between different tests entries 52 | }, 53 | 54 | 55 | // test results reporter to use 56 | // possible values: 'dots', 'progress' 57 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 58 | reporters: ['progress'], 59 | 60 | 61 | // web server port 62 | port: 9876, 63 | 64 | 65 | // enable / disable colors in the output (reporters and logs) 66 | colors: true, 67 | 68 | 69 | // level of logging 70 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 71 | logLevel: config.LOG_INFO, 72 | 73 | 74 | // enable / disable watching file and executing tests whenever any file changes 75 | autoWatch: true, 76 | 77 | 78 | // start these browsers 79 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 80 | browsers: ['Chrome'], 81 | 82 | 83 | // Continuous Integration mode 84 | // if true, Karma captures browsers, runs the tests and exits 85 | singleRun: false, 86 | 87 | // Concurrency level 88 | // how many browser should be started simultaneous 89 | concurrency: Infinity 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import utils from '../src/utils'; 2 | 3 | describe('utils', () => { 4 | let ipadUserAgent = 'Mozilla/5.0 (iPad; CPU OS 9_3_5 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) GSA/18.1.132077863 Mobile/13G36 Safari/600.1.4'; 5 | let iphoneUserAgent = 'Mozilla/5.0 (iPhone; CPU OS 9_3_5 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) GSA/18.1.132077863 Mobile/13G36 Safari/600.1.4'; 6 | let ios10UserAgent = 'Mozilla/5.0 (iPhone; CPU OS 10_0 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) GSA/18.1.132077863 Mobile/13G36 Safari/600.1.4'; 7 | let ieUserAgent = 'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko'; 8 | let edgeUserAgent = 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136'; 9 | let iPadWebViewUserAgent = 'Mozilla/5.0 (iPad; CPU OS 5_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Mobile/98176'; 10 | let iPhoneWebViewUserAgent = 'Mozilla/5.0 (iPhone; CPU OS 5_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Mobile/98176'; 11 | let fbAndroidAppUserAgent = 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.121 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/35.0.0.48.273;]'; 12 | 13 | let setUserAgent = userAgent => 14 | Object.defineProperty(navigator, "userAgent", { 15 | get() { return userAgent; }, 16 | configurable: true, 17 | }); 18 | 19 | it('#isiOS', () => { 20 | setUserAgent(iphoneUserAgent); 21 | assert.ok(utils.isiOS()); 22 | 23 | setUserAgent(ieUserAgent); 24 | assert.ok(!utils.isiOS()); 25 | }); 26 | 27 | it('#isEdge', () => { 28 | setUserAgent(edgeUserAgent); 29 | assert.ok(utils.isEdge()); 30 | }); 31 | 32 | it('#shouldUseAudioDriver', () => { 33 | setUserAgent(iphoneUserAgent); 34 | assert.ok(utils.shouldUseAudioDriver()); 35 | 36 | setUserAgent(ios10UserAgent); 37 | assert.ok(!utils.shouldUseAudioDriver()); 38 | 39 | setUserAgent(edgeUserAgent); 40 | assert.ok(!utils.shouldUseAudioDriver()); 41 | 42 | setUserAgent(iPhoneWebViewUserAgent); 43 | assert.ok(utils.shouldUseAudioDriver()); 44 | 45 | setUserAgent(iPadWebViewUserAgent); 46 | assert.ok(!utils.shouldUseAudioDriver()); 47 | 48 | setUserAgent(fbAndroidAppUserAgent); 49 | assert.ok(!utils.shouldUseAudioDriver()); 50 | }); 51 | 52 | it('#shouldUseCanvasInBetween', () => { 53 | setUserAgent(iphoneUserAgent); 54 | assert.ok(!utils.shouldUseCanvasInBetween()); 55 | setUserAgent(ios10UserAgent); 56 | assert.ok(!utils.shouldUseCanvasInBetween()); 57 | 58 | setUserAgent(edgeUserAgent); 59 | assert.ok(utils.shouldUseCanvasInBetween()); 60 | setUserAgent(ieUserAgent); 61 | assert.ok(utils.shouldUseCanvasInBetween()); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/audio.js: -------------------------------------------------------------------------------- 1 | import ThreeSixtyViewer from './three-sixty-viewer'; 2 | import THREE from 'threejs360'; 3 | 4 | export default class Audio extends ThreeSixtyViewer { 5 | constructor(options) { 6 | super(options); 7 | this.driver.addEventListener('playing', this.startVideoLoop); 8 | this.driver.addEventListener('pause', this.stopVideoLoop); 9 | this.driver.addEventListener('ended', this.stopVideoLoop); 10 | this.driver.addEventListener('stalled', this.stopVideoLoop); 11 | this.driverInitialized = false; 12 | } 13 | 14 | play() { 15 | this.driver.play(); 16 | } 17 | 18 | pause() { 19 | this.driver.pause(); 20 | } 21 | 22 | getElement() { 23 | if (this.source && this.source.tagName) { 24 | this.driver = this.source; 25 | } else { 26 | this.driver = document.createElement('audio'); 27 | this.driver.src = this.source; 28 | this.driver.loop = this.loop || false; 29 | this.driver.muted = this.muted || false; 30 | this.driver.setAttribute('crossorigin', 'anonymous'); 31 | this.driver.autoplay = this.autoplay || true; 32 | } 33 | this.source = this.driver.src; 34 | this.driver.src = ''; 35 | this.driver.load(); 36 | 37 | let video = document.createElement('video'); 38 | video.setAttribute('crossorigin', 'anonymous'); 39 | video.src = this.source; 40 | video.load(); 41 | video.addEventListener('error', this.onError); 42 | return video; 43 | } 44 | 45 | createTexture() { 46 | let texture = new THREE.VideoTexture(this.element); 47 | //TODO: we can pass all this info through the constructor 48 | texture.minFilter = THREE.LinearFilter; 49 | texture.magFilter = THREE.LinearFilter; 50 | texture.format = THREE.RGBFormat; 51 | texture.generateMipmaps = false; 52 | texture.needsUpdate = true; 53 | return texture; 54 | } 55 | 56 | startVideoLoop() { 57 | let videoFps = 1000 / 25; 58 | if (this.videoLoopId) { 59 | clearTimeout(this.videoLoopId); 60 | this.videoLoopId = null; 61 | } 62 | let videoLoop = () => { 63 | this.element.currentTime = this.driver.currentTime; 64 | this.needsUpdate = true; 65 | this.videoLoopId = setTimeout(videoLoop, videoFps); 66 | } 67 | 68 | videoLoop(); 69 | } 70 | 71 | destroy() { 72 | this.driver.style.display = ''; 73 | super.destroy(); 74 | } 75 | 76 | render() { 77 | this.target.appendChild(this.renderer.el); 78 | this.element.style.display = 'none'; 79 | this.driver.style.display = 'none'; 80 | let loop = () => { 81 | let cameraUpdated = this.controls.update(); 82 | this.renderer.render(this.scene, this.camera, this.needsUpdate || cameraUpdated); 83 | this.needsUpdate = false; 84 | this.animationFrameId = requestAnimationFrame(loop); 85 | let shouldInitializeDriver = (this.element.readyState >= this.element.HAVE_FUTURE_DATA) && !this.driverInitialized; 86 | if (shouldInitializeDriver) { 87 | this.driver.src = this.source; 88 | this.driver.load(); 89 | this.onDriverReady && this.onDriverReady(); 90 | this.driverInitialized = true; 91 | } 92 | }; 93 | loop(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/three-sixty-viewer.js: -------------------------------------------------------------------------------- 1 | import Renderer from './renderer' 2 | import utils from './utils' 3 | import Controls from './controls' 4 | 5 | import THREE from 'threejs360'; 6 | 7 | export default class ThreeSixtyViewer { 8 | constructor(options={}) { 9 | Object.assign(this, {height: 360, width: 640, initialYaw: 90, verticalPanning: true}, options); 10 | let { 11 | height, 12 | width, 13 | container, 14 | containerId, 15 | initialYaw, 16 | verticalPanning, 17 | onDragStart, 18 | onDragStop, 19 | } = this; 20 | this.renderer = new Renderer({height, width}); 21 | this.camera = new THREE.PerspectiveCamera(80, this.width / this.height, 0.1, 100); 22 | this.controls = new Controls({ 23 | camera: this.camera, 24 | renderer: this.renderer, 25 | initialYaw, 26 | verticalPanning, 27 | onDragStart, 28 | onDragStop, 29 | }); 30 | this.stopVideoLoop = this.stopVideoLoop.bind(this); 31 | this.onError = this.onError.bind(this); 32 | this.startVideoLoop = this.startVideoLoop.bind(this); 33 | this.needsUpdate = false; 34 | this.scene = this.createScene(); 35 | this.scene.add(this.camera); 36 | this.element = this.getElement(); 37 | this.elementReady = false; 38 | this.element.addEventListener('playing', this.startVideoLoop); 39 | this.element.addEventListener('pause', this.stopVideoLoop); 40 | this.element.addEventListener('ended', this.stopVideoLoop); 41 | this.element.addEventListener('loadedmetadata', this.initAfterLoadedMetadata.bind(this)) 42 | this.target = this.container ? this.container : document.querySelector(this.containerId); 43 | } 44 | 45 | initAfterLoadedMetadata() { 46 | if (this.element.readyState >= 1 && !this.elementReady) { 47 | this.texture = this.createTexture(); 48 | this.renderer.setTexture(this.texture); 49 | this.scene.getObjectByName('photo').children = [this.renderer.mesh]; 50 | this.elementReady = true; 51 | } 52 | } 53 | 54 | play() { 55 | this.element.play && this.element.play(); 56 | } 57 | 58 | pause() { 59 | this.element.pause && this.element.pause(); 60 | } 61 | 62 | centralize() { 63 | this.controls.centralize(); 64 | } 65 | 66 | stopVideoLoop() { 67 | clearTimeout(this.videoLoopId); 68 | this.videoLoopId = null; 69 | this.needsUpdate = false; 70 | } 71 | 72 | destroy() { 73 | this.element.style.display = ''; 74 | clearTimeout(this.videoLoopId); 75 | cancelAnimationFrame(this.animationFrameId); 76 | this.element.pause && this.element.pause(); 77 | this.target.removeChild(this.renderer.el); 78 | this.controls.destroy(); 79 | this.renderer.destroy(); 80 | } 81 | 82 | setSize({height, width}) { 83 | this.camera.aspect = width / height; 84 | this.camera.updateProjectionMatrix(); 85 | this.renderer.setSize({height, width}); 86 | } 87 | 88 | getElement() { 89 | if (this.source && this.source.tagName) 90 | return this.source; 91 | let video = document.createElement('video'); 92 | video.loop = this.loop || false; 93 | video.muted = this.muted || false; 94 | video.setAttribute('crossorigin', 'anonymous'); 95 | video.setAttribute('webkit-playsinline', 'true'); 96 | video.setAttribute('playsinline', 'true'); 97 | video.setAttribute('src', this.source); 98 | video.autoplay = this.autoplay !== undefined ? this.autoplay : true; 99 | video.addEventListener('error', this.onError); 100 | return video; 101 | } 102 | 103 | createTexture() { 104 | let texture = new THREE.Texture(this.element); 105 | texture.minFilter = THREE.LinearFilter; 106 | texture.magFilter = THREE.LinearFilter; 107 | texture.format = THREE.RGBFormat; 108 | texture.generateMipmaps = false; 109 | texture.needsUpdate = true; 110 | return texture; 111 | } 112 | 113 | createScene() { 114 | let scene = new THREE.Scene(); 115 | let group = new THREE.Object3D(); 116 | group.name = 'photo'; 117 | scene.add(group); 118 | return scene; 119 | } 120 | 121 | onError(err) { 122 | console.error('error loading', this.source, err); 123 | } 124 | 125 | startVideoLoop() { 126 | let videoFps = 1000 / 25; 127 | if (this.videoLoopId) { 128 | clearTimeout(this.videoLoopId); 129 | this.videoLoopId = null; 130 | } 131 | let videoLoop = () => { 132 | this.needsUpdate = true; 133 | this.videoLoopId = setTimeout(videoLoop, videoFps); 134 | } 135 | 136 | videoLoop(); 137 | } 138 | 139 | render() { 140 | this.target.appendChild(this.renderer.el); 141 | this.element.style.display = 'none'; 142 | 143 | let loop = () => { 144 | this.animationFrameId = requestAnimationFrame(loop); 145 | let cameraUpdated = this.controls.update(); 146 | this.renderer.render(this.scene, this.camera, this.needsUpdate || cameraUpdated); 147 | this.needsUpdate = false; 148 | }; 149 | 150 | this.startVideoLoop(); 151 | loop(); 152 | } 153 | } 154 | 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Kaleidoscope 4 | 5 | An embeddable, lightweight, dependency-free 360º video/image viewer 6 | 7 | [demo](http://thiago.me/kaleidoscope) 8 | 9 | ## Examples 10 | 11 | The examples code can be found in the `examples/` folder. 12 | 13 | [Viewing 360 Images](http://thiago.me/kaleidoscope/examples/image) 14 | 15 | [Viewing 360 Videos with HLS](http://thiago.me/kaleidoscope/examples/hls)* 16 | 17 | [Viewing 360 Videos with DASH](http://thiago.me/kaleidoscope/examples/dash)* 18 | 19 | [Viewing 360 Videos with progressive download](http://thiago.me/kaleidoscope/examples/mp4) 20 | 21 | * The HLS and Dash examples doesn't work on old Safari and iOS due CORS restrictions 22 | 23 | ## Usage 24 | Get the code: 25 | ```bash 26 | $ npm install kaleidoscopejs 27 | ``` 28 | 29 | Add the script to your page: 30 | ```html 31 | 32 | ``` 33 | or import the library using your favorite bundler. 34 | 35 | #### Videos 36 | ```js 37 | var viewer = new Kaleidoscope.Video({source: 'equirectangular-video.mp4', containerId: '#target'}); 38 | viewer.render(); 39 | ``` 40 | 41 | #### Images 42 | ```js 43 | var viewer = new Kaleidoscope.Image({source: 'equirectangular-image.jpg', containerId: '#target'}); 44 | viewer.render(); 45 | ``` 46 | 47 | ## Documentation 48 | 49 | #### Kaleidoscope.Video 50 | 51 | ```js 52 | let viewer = new Kaleidoscope.Video(options) 53 | ``` 54 |
55 |
56 | options 57 |
58 |
59 | Object. 60 |
61 |
62 | 63 | `options.source` source video to be played. This can be either a video tag or an url to the video file. Passing a tag is useful when embedding in player or using adaptative streaming. An example of how to use it with HLS.js can be found [here.](https://github.com/thiagopnts/kaleidoscope/issues/16#issuecomment-270478823) 64 | 65 | `options.containerId` is where you want to render the 360, this is going to be retrieved via `document.querySelector` and when you call `render()` the 360 canvas will be append to it. 66 | 67 | `options.container` HTML element to attach the 360 canvas to. You should always either pass a `containerId` or a `container`. 68 | 69 | `options.height` height of the 360 canvas. Defaults to `360`. 70 | 71 | `options.width` width of the 360 canvas. Defaults to `640`. 72 | 73 | `options.autoplay` to autoplay the video once rendered. Doesn't work on mobile. Defaults to `true`. 74 | 75 | `options.muted` to define if the video should start muted. Defaults to `false`. 76 | 77 | `options.initialYaw` number to define initial yaw of 360, should be in degrees(45, 90, 180 etc). 78 | 79 | `options.loop` to define if the video should loop. Defaults to `false`. 80 | 81 | `options.onError` callback to when something goes wrong. 82 | 83 | `options.verticalPanning` disables vertical panning. Defaults to `false`. 84 | 85 | `options.onDragStart` callback called when user interaction starts. 86 | 87 | `options.onDragStop` callback called when user interaction ends. 88 | 89 | `viewer.render()` renders the canvas in the defined `containerId` or `container`. 90 | 91 | `viewer.play()` starts the current video. Useful for mobile. 92 | 93 | `viewer.pause()` pauses the current video. 94 | 95 | `viewer.centralize()` move camera back to the original center. 96 | 97 | `viewer.setSize({height, width})` sets canvas size. 98 | 99 | `viewer.destroy()` destroy viewer cleaning up all used resources. 100 | 101 | #### Kaleidoscope.Image 102 | 103 | ```js 104 | let viewer = new Kaleidoscope.Image(options) 105 | ``` 106 |
107 |
108 | options 109 |
110 |
111 | Object. 112 |
113 |
114 | 115 | `options.source` source of the image to be rendered. This can be either an url to the image or the img tag itself. 116 | 117 | `options.containerId` is where you want to render the 360, this is going to be retrieved via `document.querySelector` and when you call `render()` the 360 canvas will be append to it. 118 | 119 | `options.container` HTML element to attach the 360 canvas to. You should always either pass a `containerId` or a `container`. 120 | 121 | `options.height` height of the 360 canvas. Defaults to `360`. 122 | 123 | `options.width` width of the 360 canvas. Defaults to `640`. 124 | 125 | `options.initialYaw` number to define initial yaw of 360, should be in degrees(45, 90, 180 etc). 126 | 127 | `options.verticalPanning` disables vertical panning. Defaults to `false`. 128 | 129 | `options.onDragStart` callback called when user interaction starts. 130 | 131 | `options.onDragStop` callback called when user interaction ends. 132 | 133 | `options.onError` callback to when something goes wrong. 134 | 135 | `viewer.render()` renders the canvas in the defined `containerId` or `container`. 136 | 137 | `viewer.centralize()` move camera back to the original center. 138 | 139 | `viewer.setSize({height, width})` sets canvas size. 140 | 141 | `viewer.destroy()` destroy viewer cleaning up all used resources. 142 | 143 | ## Supported Browsers 144 | 145 | - Google Chrome 146 | - Microsoft Edge 147 | - Firefox 148 | - Internet Explorer 11 149 | - Safari 150 | - Chrome Android\* 151 | - Safari iOS 152 | 153 | \*Most recent versions. 154 | 155 | ## Known issues 156 | 157 | 360 videos doesn't work in Safari, IE 11, Microsoft Edge, Android and iOS if the video is served from a different domain, due some CORS [implementation bugs](https://bugs.webkit.org/show_bug.cgi?id=135379). 158 | 159 | ## Donations 160 | 161 | Would you like to buy me a beer? 162 | 163 | ETH 0x2230591c013e4E7e3B819B2B51496e34ED884c03 164 | 165 | BTC 16qKaBh6DuuJuaQp4x3Eut8MAsVnpacVm5 166 | -------------------------------------------------------------------------------- /src/controls.js: -------------------------------------------------------------------------------- 1 | import THREE from 'threejs360'; 2 | import utils from './utils' 3 | 4 | let easeOutBack = k => { 5 | let s = 1.70158; 6 | return --k * k * ((s + 1) * k + s) + 1; 7 | }; 8 | 9 | export default class Controls { 10 | constructor(options) { 11 | Object.assign(this, options); 12 | this.el = this.renderer.el; 13 | this.theta = this.initialYaw * Math.PI / 180; 14 | this.phi = 0; 15 | this.velo = 0.02; 16 | this.rotateStart = new THREE.Vector2(); 17 | this.rotateEnd = new THREE.Vector2(); 18 | this.rotateDelta = new THREE.Vector2(); 19 | this.orientation = new THREE.Quaternion(); 20 | this.euler = new THREE.Euler(); 21 | this.momentum = false; 22 | this.isUserInteracting = false; 23 | this.addDraggableStyle(); 24 | this.onMouseMove = this.onMouseMove.bind(this); 25 | this.onMouseDown = this.onMouseDown.bind(this); 26 | this.onMouseUp = this.onMouseUp.bind(this); 27 | this.onTouchStart = e => this.onMouseDown({clientX: e.touches[0].pageX, clientY: e.touches[0].pageY}); 28 | this.onTouchMove = e => this.onMouseMove({clientX: e.touches[0].pageX, clientY: e.touches[0].pageY}); 29 | this.onTouchEnd = _ => this.onMouseUp(); 30 | this.onDeviceMotion = this.onDeviceMotion.bind(this); 31 | this.onMessage = this.onMessage.bind(this); 32 | this.bindEvents(); 33 | } 34 | 35 | bindEvents() { 36 | this.el.addEventListener('mouseleave', this.onMouseUp); 37 | this.el.addEventListener('mousemove', this.onMouseMove); 38 | this.el.addEventListener('mousedown', this.onMouseDown); 39 | this.el.addEventListener('mouseup', this.onMouseUp); 40 | this.el.addEventListener('touchstart', this.onTouchStart); 41 | this.el.addEventListener('touchmove', this.onTouchMove); 42 | this.el.addEventListener('touchend', this.onTouchEnd); 43 | if (!this.isInIframe()) 44 | window.addEventListener('devicemotion', this.onDeviceMotion); 45 | window.addEventListener('message', this.onMessage); 46 | } 47 | 48 | centralize() { 49 | let endTheta = this.initialYaw * Math.PI / 180; 50 | 51 | let duration = 750; 52 | let startTheta = this.theta; 53 | let startPhi = this.phi; 54 | let start = Date.now(); 55 | 56 | let animate = () => { 57 | let progress = Date.now() - start; 58 | let elapsed = progress / duration; 59 | elapsed = elapsed > 1 ? 1 : elapsed; 60 | if (progress >= duration) { 61 | return cancelAnimationFrame(id); 62 | } 63 | this.theta = startTheta + (endTheta - startTheta) * easeOutBack(elapsed); 64 | this.phi = startPhi + (0 - startPhi) * easeOutBack(elapsed); 65 | return requestAnimationFrame(animate); 66 | }; 67 | let id = animate(); 68 | } 69 | 70 | isInIframe() { 71 | try { 72 | return window.self !== window.top; 73 |    } catch (e) { 74 | return true; 75 |    } 76 | } 77 | 78 | destroy() { 79 | this.el.removeEventListener('mouseleave', this.onMouseUp); 80 | this.el.removeEventListener('mousemove', this.onMouseMove); 81 | this.el.removeEventListener('mousedown', this.onMouseDown); 82 | this.el.removeEventListener('mouseup', this.onMouseUp); 83 | this.el.removeEventListener('touchstart', this.onTouchStart); 84 | this.el.removeEventListener('touchmove', this.onTouchMove); 85 | this.el.removeEventListener('touchend', this.onTouchEnd); 86 | window.removeEventListener('devicemotion', this.onDeviceMotion); 87 | window.removeEventListener('message', this.onMessage); 88 | } 89 | 90 | getCurrentStyle() { 91 | return `height: ${parseInt(this.el.style.height, 10)}px; width: ${parseInt(this.el.style.width, 10)}px; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: rgba(0,0,0,0);`; 92 | } 93 | 94 | addDraggingStyle() { 95 | this.el.setAttribute('style', `${this.getCurrentStyle()} cursor: -webkit-grabbing; cursor: -moz-grabbing; cursor: grabbing;`); 96 | } 97 | 98 | addDraggableStyle() { 99 | this.el.setAttribute('style', `${this.getCurrentStyle()} cursor: -webkit-grab; cursor: -moz-grab; cursor: grab;`); 100 | } 101 | 102 | onMessage(event) { 103 | let {orientation, portrait, rotationRate} = event.data; 104 | if (!rotationRate) return; 105 | this.onDeviceMotion({orientation, portrait, rotationRate}); 106 | } 107 | 108 | onDeviceMotion(event) { 109 | let portrait = event.portrait !== undefined ? event.portrait : window.matchMedia("(orientation: portrait)").matches; 110 | let orientation; 111 | if (event.orientation !== undefined) { 112 | orientation = event.orientation; 113 | } else if (window.orientation !== undefined) { 114 | orientation = window.orientation; 115 | } else { 116 | orientation = -90; 117 | } 118 | let alpha = THREE.Math.degToRad(event.rotationRate.alpha); 119 | let beta = THREE.Math.degToRad(event.rotationRate.beta); 120 | if (portrait) { 121 | this.phi = this.verticalPanning ? this.phi + alpha * this.velo : this.phi; 122 | this.theta = this.theta - beta * this.velo * -1; 123 | } else { 124 | if (this.verticalPanning) { 125 | this.phi = orientation === -90 ? this.phi + beta * this.velo : this.phi - beta * this.velo; 126 | } 127 | this.theta = orientation === -90 ? this.theta - alpha * this.velo : this.theta + alpha * this.velo; 128 | } 129 | 130 | this.adjustPhi(); 131 | } 132 | 133 | onMouseMove(event) { 134 | if (!this.isUserInteracting) { 135 | return; 136 | } 137 | this.rotateEnd.set(event.clientX, event.clientY); 138 | 139 | this.rotateDelta.subVectors(this.rotateEnd, this.rotateStart); 140 | this.rotateStart.copy(this.rotateEnd); 141 | 142 | this.phi = this.verticalPanning ? this.phi + 2 * Math.PI * this.rotateDelta.y / this.renderer.height * 0.3 : this.phi; 143 | this.theta += 2 * Math.PI * this.rotateDelta.x / this.renderer.width * 0.5; 144 | this.adjustPhi(); 145 | } 146 | 147 | adjustPhi() { 148 | // Prevent looking too far up or down. 149 | this.phi = THREE.Math.clamp(this.phi, -Math.PI / 1.95, Math.PI / 1.95); 150 | } 151 | 152 | onMouseDown(event) { 153 | this.addDraggingStyle(); 154 | this.rotateStart.set(event.clientX, event.clientY); 155 | this.isUserInteracting = true; 156 | this.momentum = false; 157 | this.onDragStart && this.onDragStart(); 158 | } 159 | 160 | inertia() { 161 | if (!this.momentum) return; 162 | this.rotateDelta.y *= 0.90; 163 | this.rotateDelta.x *= 0.90; 164 | this.theta += 0.005 * this.rotateDelta.x; 165 | this.phi = this.verticalPanning ? this.phi + 0.005 * this.rotateDelta.y : this.phi; 166 | this.adjustPhi(); 167 | } 168 | 169 | onMouseUp() { 170 | this.isUserInteracting && this.onDragStop && this.onDragStop(); 171 | this.addDraggableStyle(); 172 | this.isUserInteracting = false; 173 | this.momentum = true; 174 | this.inertia(); 175 | } 176 | 177 | update() { 178 | if ((this.phi === this.previousPhi) && (this.theta === this.previousTheta)) 179 | return false; 180 | this.previousPhi = this.phi; 181 | this.previousTheta = this.theta; 182 | this.euler.set(this.phi, this.theta, 0, 'YXZ'); 183 | this.orientation.setFromEuler(this.euler); 184 | this.camera.quaternion.copy(this.orientation); 185 | this.inertia(); 186 | return true; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------