import { Renderer } from './beam' import { Camera } from './beam' import { vec2, vec3, mat4 } from './beam' import { Container, Mesh, Material, Texture, SphereGeometryBuffer, PlaneGeometryBuffer } from './beam' // GLSL shaders as strings import GlobeVS from './globe-vs.glsl?raw' import GlobeFS from './globe-fs.glsl?raw' const FOV = 1 // Camera Field of view; we use 1 to prevent strong perspective effect on the globe /** get 3D position on a sphere from longitute lattitude */ const lonLatToVector3 = (lng, lat) => { const phi = (90 - lat) * (Math.PI / 180) const theta = (lng + 180) * (Math.PI / 180) const x = -(Math.sin(phi) * Math.cos(theta)) const z = Math.sin(phi) * Math.sin(theta) const y = Math.cos(phi) return [x,y,z] } // Convert degrees to radians const degToRad = deg => deg * Math.PI / 180 class WebglGlobe { // Constructor constructor (options) { this.$el = options.el // The DOM reference node this.options = options this.options.autoRotationSpeed = this.options.autoRotationSpeed || 0 this.options.scrollSmoothing = this.options.scrollSmoothing || 0.5 // Smooth the globe position to avoid janks on scroll (lower == smoother) this.options.cameraDistance = 1 // this.options.cameraDistance || 1 // A multiplier to move camera backward or forward this.options.opacity = this.options.opacity || 1 this.locations = options.markers // List of locations with their options this._canUpdate = false this.hasUpdateCameraPos = false this.referenceHeight = 1 // Used to set camera distance from globe where referenceHeight == window height this.currMarkerScrollOffset = 0 this.markersScrollOffset = 0 this.globeScrollOffset = 0 this.globeAutoRotation = degToRad(this.options.rotationStart * -1) || 0 this._isHoverMarker = false let gl let canvas = document.createElement('canvas') try { gl = canvas.getContext('webgl') } catch (x) { try { gl = canvas.getContext('experimental-webgl') } catch (x) { gl = null } } this.supportWebgl = gl !== null if (this.supportWebgl) { this.buildWebglScene() this.resize() } } // Build buildWebglScene () { // Renderer this.renderer = new Renderer({ // To allow transparent background on webgl canvas alpha: true, // Enable antialiasing only if screen is small with no retina (for performances reasons) antialias: window.innerWidth < 768 || window.devicePixelRatio == 1 ? true : false, }) const elParent = document.body // we put the canvas at the end of body tag as 'position:fixed' // this is to avoid having a too big canvas if the globe needs to be very large: // so instead we move the globe as we scroll this.renderer.canvas.style.position = 'fixed' this.renderer.canvas.style.top = 0 this.renderer.canvas.style.left = 0 this.renderer.canvas.style.pointerEvents = 'none' this.renderer.canvas.style.zIndex = 100 elParent.appendChild(this.renderer.canvas) // The markers DOM nodes wrapper // this wrapper is added just next to the canvas, at the end of body tag this.$markerWrapper = document.createElement('div') this.$markerWrapper.classList.add('globe__markers') this.$markerWrapper.style.position = 'fixed' this.$markerWrapper.style.top = 0 this.$markerWrapper.style.left = 0 this.$markerWrapper.style.pointerEvents = 'none' elParent.appendChild(this.$markerWrapper) // Load worldmap texture this.texture = Texture.fromUrl(this.renderer.gl, this.options.texture, { loaded: () => { //TODO: use only one RAF if possible requestAnimationFrame(() => { requestAnimationFrame(() => { this.imageLoaded = true }) }) } }) this.scene = new Container() // Setup camera this.camera = new Camera({ fov: 1, near: 0.1, far: 1000, type: 'perspective', orbitControl: true, firstPerson: false, lookAt: [0,0,0], position: [0,0,0], pointerParent: this.$el }) this.camera.lookAt = vec3.create() /** * used to compute screen position of markers, * to move the corresponding DOM nodes */ this.viewProjectionMatrix = mat4.create() this.cameraPosition = vec3.create() this.globeMesh = new Mesh() this.globeMesh.material = new Material(this.renderer.gl, { uniforms: { tInput: this.texture, uCameraOffsetY: 0, }, vertexShader: GlobeVS, fragmentShader: GlobeFS, }) this.globeMesh.geometry = new SphereGeometryBuffer(this.renderer.gl, { radius: this.referenceHeight / 2, widthSegments: 100, heightSegments: 100 }) this.scene.add(this.globeMesh) // this.refPlane = new Mesh() // this.refPlane.material = new Material(this.renderer.gl, { // uniforms: { // color: [0,1,0] // } // }) // this.refPlane.geometry = new PlaneGeometryBuffer(this.renderer.gl, { // width: this.referenceHeight, height: this.referenceHeight // }) // this.scene.add(this.refPlane) /** * Create DOM nodes for markers and 3D positions */ this.markers = [] let markers = this.locations // Instance all markers for (let i = 0; i < markers.length; i++) { // Position marker let p = lonLatToVector3(markers[i].lng, markers[i].lat) // Scale marker position to fit globe size p[0] *= this.referenceHeight / 2 p[1] *= this.referenceHeight / 2 p[2] *= this.referenceHeight / 2 // Wrap marker in link let el = document.createElement('a') el.style.pointerEvents = 'auto' el.setAttribute('href', `/${markers[i].countrySlug}/${markers[i].slug}`) el.setAttribute('sveltekit-noscroll', '') if (markers[i].className) el.classList.add(markers[i].className) // Add label let span = document.createElement('span') span.classList.add('marker__label') el.appendChild(span) // Add city label let spanCity = document.createElement('span') spanCity.classList.add('marker__city') spanCity.innerHTML = markers[i].name span.appendChild(spanCity) // Add country label let spanCountry = document.createElement('span') spanCountry.classList.add('marker__country') spanCountry.innerHTML = markers[i].countryName span.appendChild(spanCountry) // Add class el.classList.add('marker') // Add a class if opacity is below 1 if (this.options.opacity < 1) { el.classList.add('is-light') } // Callback on click el.addEventListener('click', () => { this.options.onLinkClicked && this.options.onLinkClicked() }) // Add class on hover el.addEventListener('mouseenter', () => { el.classList.add('hover') // Stop globe rotation this._isHoverMarker = true // Clear timeout to be sure clearTimeout(this.hoverTimeout) }) el.addEventListener('mouseleave', () => { // Restart rotation after a little delay this.hoverTimeout = setTimeout(() => this._isHoverMarker = false, 400) }) el.addEventListener('animationend', () => { el.classList.remove('hover') }) // Append marker to HTML this.$markerWrapper.appendChild(el) this.markers.push({ el: el, position: p, screenPosition: [0,0] }) } } // Resize method resize () { if (!this.supportWebgl) { return } this.width = window ? window.innerWidth : 0 this.height = window ? window.innerHeight : 0 //fit globe to container height this.options.cameraDistance = this.height / this.$el.clientHeight; // Remove retina on small screen (aka mobile) to boost perfs this.renderer.setPixelRatio(window.innerWidth < 768 ? 1 : window.devicePixelRatio) this.renderer.resize(this.width , this.height) // Update camera aspect ratio this.camera.aspect = this.width / this.height this.camera.updateProjectionMatrix() // Distance to put the camera when rotating around the globe this.camera._cameraDistance = (this.referenceHeight * this.options.cameraDistance) / 2 / Math.tan(Math.PI * FOV / 360) this.camera.update(true) /** * When markers are behind the globe, clamp their position * to this size to make them move along the circle edge */ this.circleScreenSize = (this.height / 2) * (1 / this.options.cameraDistance) } /** * As the camera rotates arount the globe, we cant simply move the Y position of the camera or the globe * Instead, we move the globe vertex inside the vertex shadder after compute the project on screen, so we pass the 'scroll' position to a uniform */ updateCameraPos (y, scrollDiff) { this.globeScrollOffset = y this.markersScrollOffset = scrollDiff // Avoid jump due to smoothing when setting it for first time as the inital values are 0 if (!this.hasUpdateCameraPos) { this.hasUpdateCameraPos = true if (this.globeMesh.material.uniforms.uCameraOffsetY) { this.globeMesh.material.uniforms.uCameraOffsetY.value = this.globeScrollOffset } this.currMarkerScrollOffset = this.markersScrollOffset } } // Destroy destroy () { this.disable() // Stop render loop document.body.removeChild(this.$markerWrapper) document.body.removeChild(this.renderer.canvas) this.camera.delete() // To remove event listeners } /** * Flag to stop rendering the webgl if the globe isnt visible * This helps saving perfs and battery */ enable () { this.renderer.canvas.style.opacity = this.options.opacity this.$markerWrapper.style.opacity = 1 this._canUpdate = true } disable () { this.renderer.canvas.style.opacity = 0 this.$markerWrapper.style.opacity = 0 this._canUpdate = false } // Update update () { if (!this.supportWebgl || !this._canUpdate || !this.imageLoaded || !this.hasUpdateCameraPos) { return } if (this.globeMesh.material.uniforms.uCameraOffsetY) { this.globeMesh.material.uniforms.uCameraOffsetY.value += (this.globeScrollOffset - this.globeMesh.material.uniforms.uCameraOffsetY.value) * this.options.scrollSmoothing } this.currMarkerScrollOffset += (this.markersScrollOffset - this.currMarkerScrollOffset) * this.options.scrollSmoothing // Compute the camera view-projection matrix to use it on the markers this.camera.update() vec3.set(this.cameraPosition, this.camera.worldMatrix[12], this.camera.worldMatrix[13], this.camera.worldMatrix[14]) mat4.copy(this.viewProjectionMatrix, this.camera.projectionMatrix) mat4.multiply(this.viewProjectionMatrix, this.viewProjectionMatrix, this.camera.inverseWorldMatrix) // Auto rotate the globe if not hover a marker if (!this._isHoverMarker) { this.globeAutoRotation += this.options.autoRotationSpeed this.globeMesh.rotation[1] = this.globeAutoRotation } this.globeMesh.updateMatrix() this.globeMesh.updateWorldMatrix() let screenPos = vec3.create() this.markers.forEach((marker, i) => { // Get marker 3D position and project it on screen to get 2D position vec3.set(screenPos, marker.position[0], marker.position[1], marker.position[2]) vec3.transformMat4(screenPos, screenPos, this.globeMesh.worldMatrix) vec3.transformMat4(screenPos, screenPos, this.viewProjectionMatrix) // Marker 2D screen position (starting from top left corner of screen) let x = ((screenPos[0] + 1) / 2) * this.width let y = (1. - (screenPos[1] + 1) / 2) * this.height // Compute marker Normal let N = vec3.create() vec3.set(N, marker.position[0], marker.position[1], marker.position[2]) vec3.transformMat4(N, N, this.globeMesh.worldMatrix) vec3.normalize(N, N) // Compute view vector (camera direction) let V = vec3.create() vec3.set(V, marker.position[0], marker.position[1], marker.position[2]) vec3.subtract(V, V, this.cameraPosition) vec3.normalize(V, V) // Marker is behind the globe: clamp it to the globe edge if (vec3.dot(V, N) * -1 < 0) { let dir = vec2.create() vec2.set(dir, x, y) let center = vec2.create() vec2.set(center, this.width/2, this.height/2) let dir2d = vec2.clone(dir) // vec2.clone(dir, dir) vec2.subtract(dir2d, dir2d, center) vec2.normalize(dir2d, dir2d) vec2.scale(dir2d, dir2d, this.circleScreenSize) vec2.add(dir2d, dir2d, center) dir2d[1] += this.currMarkerScrollOffset marker.el.style.transform = `translate(${dir2d[0]}px, ${dir2d[1]}px) translateZ(0)` marker.el.classList.remove('is-active') } // Marker is in front of the globe; update 2D position else { y += this.currMarkerScrollOffset marker.el.style.transform = `translate(${x}px, ${y}px) translateZ(0)` marker.el.classList.add('is-active') } }) // Render WebGL frame this.renderer.clearColor(0,0,0,0) //[RGBA] alpha is set to 0 to have a transparent background on the webgl this.renderer.clear() this.renderer.render(this.scene, this.camera) } } // window.WebglGlobe = WebglGlobe export default WebglGlobe