390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
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 |