Implement Interactive Globe on Homepage
This commit is contained in:
130
src/components/organisms/InteractiveGlobe.svelte
Normal file
130
src/components/organisms/InteractiveGlobe.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte'
|
||||
import { getPosition, getRandomElement } from '$utils/functions'
|
||||
|
||||
export let type: string = undefined
|
||||
export let autoRotate: boolean = true
|
||||
export let scrollSmooth: number = 0.5
|
||||
export let opacity: number = 1
|
||||
|
||||
const { continent, location } = getContext('global')
|
||||
|
||||
let globe: any
|
||||
let Globe: any
|
||||
let globeEl: HTMLElement
|
||||
let windowHeight: number, windowWidth: number
|
||||
let containerTop: number = 0, containerHeight: number = 0
|
||||
let observer: IntersectionObserver
|
||||
|
||||
const randomContinent = getRandomElement(continent.filter(cont => cont.countries))
|
||||
const globeResolution = windowWidth > 1440 && window.devicePixelRatio > 1 ? '4k' : '2k'
|
||||
const locations = location.map(({ name, slug, country, coordinates: { coordinates }}: any) => ({
|
||||
name,
|
||||
slug,
|
||||
countryName: country.name,
|
||||
countrySlug: country.slug,
|
||||
lat: coordinates[1],
|
||||
lng: coordinates[0],
|
||||
// className: location.close ? 'is-close' : '',
|
||||
}))
|
||||
|
||||
|
||||
/**
|
||||
* Globe update
|
||||
*/
|
||||
const update = () => {
|
||||
requestAnimationFrame(update)
|
||||
globe.update()
|
||||
}
|
||||
|
||||
/**
|
||||
* When scrolling
|
||||
*/
|
||||
const handleScroll = () => {
|
||||
let scrollDiff = (containerTop + windowHeight + (containerHeight - windowHeight) / 2) - document.documentElement.scrollTop
|
||||
let scrollRatio = (1 - (scrollDiff / windowHeight)) * 2
|
||||
if (globe) {
|
||||
globe.updateCameraPos(scrollRatio, scrollDiff - windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When resizing
|
||||
*/
|
||||
const handleResize = () => {
|
||||
if (globeEl) {
|
||||
containerTop = getPosition(globeEl).top
|
||||
containerHeight = globeEl.clientHeight
|
||||
}
|
||||
if (globe) {
|
||||
globe.resize()
|
||||
globe.update()
|
||||
}
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// Load globe library
|
||||
Globe = await import('$modules/globe')
|
||||
|
||||
// Instantiate globe
|
||||
globe = new Globe.default({
|
||||
el: globeEl,
|
||||
//cameraDistance: size, // Smaller number == larger globe
|
||||
autoRotationSpeed: autoRotate ? -0.0025 : 0,
|
||||
rotationStart: randomContinent.rotation, // In degrees
|
||||
scrollSmoothing: scrollSmooth,
|
||||
opacity,
|
||||
texture: `/images/globe-map-${globeResolution}.png`,
|
||||
markers: locations,
|
||||
onLinkClicked: () => {}
|
||||
})
|
||||
|
||||
// Observe the globe
|
||||
observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
globe.enable()
|
||||
} else {
|
||||
globe.disable()
|
||||
}
|
||||
})
|
||||
}, {
|
||||
threshold: 0,
|
||||
rootMargin: '0px 0px 0px'
|
||||
})
|
||||
observer.observe(globeEl)
|
||||
|
||||
// Run the globe
|
||||
update()
|
||||
handleResize()
|
||||
|
||||
|
||||
// Destroy
|
||||
return () => {
|
||||
if (globe) {
|
||||
globe.destroy()
|
||||
observer.unobserve(globeEl)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
bind:innerHeight={windowHeight}
|
||||
bind:innerWidth={windowWidth}
|
||||
on:scroll={handleScroll}
|
||||
on:resize={handleResize}
|
||||
/>
|
||||
|
||||
|
||||
<section id="globe">
|
||||
{#if type === 'cropped'}
|
||||
<div class="globe--cropped">
|
||||
<div class="globe" bind:this={globeEl} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="globe" bind:this={globeEl} />
|
||||
{/if}
|
||||
</section>
|
||||
@@ -24,7 +24,6 @@ const degToRad = deg => deg * Math.PI / 180
|
||||
|
||||
|
||||
class WebglGlobe {
|
||||
|
||||
// Constructor
|
||||
constructor (options) {
|
||||
this.$el = options.el // The DOM reference node
|
||||
@@ -34,7 +33,7 @@ class WebglGlobe {
|
||||
this.options.cameraDistance = 1 // this.options.cameraDistance || 1 // A multiplier to move camera backward or forward
|
||||
this.options.opacity = this.options.opacity || 1
|
||||
|
||||
this.cities = options.markers // List of cities with their options
|
||||
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
|
||||
@@ -157,7 +156,7 @@ class WebglGlobe {
|
||||
* Create DOM nodes for markers and 3D positions
|
||||
*/
|
||||
this.markers = []
|
||||
let markers = this.cities
|
||||
let markers = this.locations
|
||||
|
||||
// Instance all markers
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
@@ -362,7 +361,7 @@ class WebglGlobe {
|
||||
vec2.set(dir, x, y)
|
||||
let center = vec2.create()
|
||||
vec2.set(center, this.width/2, this.height/2)
|
||||
let dir2d = vec2.clone(dir, dir)
|
||||
let dir2d = vec2.clone(dir) // vec2.clone(dir, dir)
|
||||
vec2.subtract(dir2d, dir2d, center)
|
||||
vec2.normalize(dir2d, dir2d)
|
||||
vec2.scale(dir2d, dir2d, this.circleScreenSize)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
location {
|
||||
name
|
||||
slug
|
||||
coordinates
|
||||
country {
|
||||
name
|
||||
slug
|
||||
@@ -58,6 +59,7 @@
|
||||
continent {
|
||||
name
|
||||
slug
|
||||
rotation
|
||||
countries {
|
||||
slug
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InteractiveGlobe />
|
||||
|
||||
<Locations
|
||||
locations={location}
|
||||
/>
|
||||
|
||||
@@ -1,119 +1,3 @@
|
||||
/* ==========================================================================
|
||||
PAGE TRANSITION
|
||||
========================================================================== */
|
||||
.transition {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 400;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: wait;
|
||||
|
||||
&, * {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
// Content
|
||||
&__loader {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Background
|
||||
&__background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $color-primary;
|
||||
transform-origin: 50% 0;
|
||||
}
|
||||
|
||||
// Hidden
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
REVEAL ANIMATIONS
|
||||
========================================================================== */
|
||||
.anim-mask {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
KEYFRAMES ANIMATIONS
|
||||
========================================================================== */
|
||||
// Rotate button dashes
|
||||
@keyframes rotateDashes {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
** Spinning globe
|
||||
*/
|
||||
@keyframes moveContinents {
|
||||
0% { transform: translate(0,0); }
|
||||
100% { transform: translate(-80.26px, 28.2px); }
|
||||
}
|
||||
.anim-spinGlobe {
|
||||
animation: moveContinents 1.7s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
// Small
|
||||
@keyframes moveContinentsSmall {
|
||||
0% { transform: translate(0,0); }
|
||||
100% { transform: translate(-96.95px, 0); }
|
||||
}
|
||||
.anim-spinGlobeSmall {
|
||||
animation: moveContinentsSmall 1.5s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
** Layout
|
||||
*/
|
||||
// List
|
||||
@keyframes layoutListOdd {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(2px); }
|
||||
}
|
||||
@keyframes layoutListEven {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(-4px); }
|
||||
}
|
||||
|
||||
// Grid
|
||||
@keyframes layoutGridOdd {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(2px); }
|
||||
}
|
||||
@keyframes layoutGridEven {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
** Globe
|
||||
*/
|
||||
|
||||
187
src/style/modules/_globe.scss
Normal file
187
src/style/modules/_globe.scss
Normal file
@@ -0,0 +1,187 @@
|
||||
// Globe
|
||||
.globe {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 1315px;
|
||||
height: clamp(700px, 100vw, 1315px);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
|
||||
@include bp (sm) {
|
||||
// height: 130vw;
|
||||
}
|
||||
@include bp (md) {
|
||||
// height: 112vw;
|
||||
}
|
||||
@include bp (xl) {
|
||||
// height: 100vw;
|
||||
}
|
||||
|
||||
// DEBUG //
|
||||
// background: rgba(red, 0.2);
|
||||
// &:after {
|
||||
// content: "";
|
||||
// display: block;
|
||||
// position: absolute;
|
||||
// top: 50%;
|
||||
// left: 0;
|
||||
// background: blue;
|
||||
// width: 100%;
|
||||
// height: 2px;
|
||||
// margin-top: -1px;
|
||||
// }
|
||||
// END DEBUG //
|
||||
|
||||
/*
|
||||
** Cropped globe
|
||||
*/
|
||||
&--cropped {
|
||||
overflow: hidden;
|
||||
height: clamp(300px, 30vw, 500px);
|
||||
}
|
||||
|
||||
/*
|
||||
** Markers
|
||||
*/
|
||||
&__markers {
|
||||
z-index: 210;
|
||||
|
||||
// When dragging
|
||||
&.is-grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
// Marker
|
||||
.marker {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
padding: 4px;
|
||||
opacity: 1;
|
||||
will-change: transform;
|
||||
|
||||
// Dot
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: $color-secondary;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
span {
|
||||
transition: color 0.4s $ease-quart, opacity 0.3s $ease-inout;
|
||||
}
|
||||
|
||||
// Hover glow effect
|
||||
&.hover {
|
||||
&:before {
|
||||
animation: globeMarkerPulse 1s;
|
||||
}
|
||||
}
|
||||
|
||||
// Label
|
||||
&__label {
|
||||
position: absolute;
|
||||
bottom: -16px;
|
||||
left: 16px;
|
||||
color: transparent;
|
||||
}
|
||||
// Location city
|
||||
&__city {
|
||||
font-family: $font-serif;
|
||||
font-size: rem(18px);
|
||||
line-height: 1;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(24px);
|
||||
}
|
||||
}
|
||||
// Location country
|
||||
&__country {
|
||||
display: block;
|
||||
opacity: 0.8;
|
||||
font-family: $font-sans;
|
||||
font-size: rem(8px);
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
|
||||
@include bp (sm) {
|
||||
font-size: rem(10px);
|
||||
}
|
||||
}
|
||||
|
||||
// Active
|
||||
&.is-active {
|
||||
&, span {
|
||||
opacity: 1;
|
||||
}
|
||||
.marker {
|
||||
&__city {
|
||||
color: $color-secondary;
|
||||
}
|
||||
&__country {
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Is light
|
||||
&.is-light {
|
||||
&.is-active {
|
||||
.marker {
|
||||
&__city {
|
||||
color: #fff;
|
||||
}
|
||||
&__country {
|
||||
color: #d2b7e4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Left positioned
|
||||
&.is-left {
|
||||
.marker {
|
||||
&__label {
|
||||
left: auto;
|
||||
right: 32px;
|
||||
}
|
||||
&__country {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marker is close to another one
|
||||
// Show the marker infos only on hover
|
||||
&.is-close {
|
||||
// Dot
|
||||
&:before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
// Label
|
||||
.marker__label {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Show labels on hover
|
||||
&:hover {
|
||||
.marker__label {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
// Pages
|
||||
@import "pages/homepage";
|
||||
|
||||
// Modules
|
||||
@import "modules/globe";
|
||||
|
||||
|
||||
// Atomic Design System
|
||||
// Atoms
|
||||
@@ -46,4 +49,4 @@
|
||||
// @import "pages/page";
|
||||
|
||||
// Misc
|
||||
// @import "animations";
|
||||
@import "animations";
|
||||
@@ -4,3 +4,30 @@
|
||||
export const lerp = (start: number, end: number, amt: number): number => {
|
||||
return (1 - amt) * start + amt * end
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return a random element from an array
|
||||
*/
|
||||
export const getRandomElement = (array: any[]): any => {
|
||||
return ~~(array.length * Math.random())
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a DOM element's position
|
||||
*/
|
||||
export const getPosition = (node, scope?: HTMLElement) => {
|
||||
const root = scope || document
|
||||
let offsetTop = node.offsetTop
|
||||
let offsetLeft = node.offsetLeft
|
||||
while (node && node.offsetParent && node.offsetParent != document && node !== root && root !== node.offsetParent) {
|
||||
offsetTop += node.offsetParent.offsetTop
|
||||
offsetLeft += node.offsetParent.offsetLeft
|
||||
node = node.offsetParent
|
||||
}
|
||||
return {
|
||||
top: offsetTop,
|
||||
left: offsetLeft
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user