310 lines
8.9 KiB
Vue
310 lines
8.9 KiB
Vue
<template>
|
|
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
|
|
<div
|
|
v-for="location in locations"
|
|
:key="location.name"
|
|
:class="{
|
|
'opacity-0': !showLabels,
|
|
hidden: !isLocationVisible(location),
|
|
'z-40': location.clicked,
|
|
}"
|
|
:style="{
|
|
position: 'absolute',
|
|
left: `${location.screenPosition?.x || 0}px`,
|
|
top: `${location.screenPosition?.y || 0}px`,
|
|
}"
|
|
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
|
|
@click="toggleLocationClicked(location)"
|
|
>
|
|
<div
|
|
:class="{
|
|
'animate-pulse': location.active,
|
|
'border-gray-400': !location.active,
|
|
'border-purple bg-purple': location.active,
|
|
'border-dashed': !location.active,
|
|
'opacity-40': !location.active,
|
|
}"
|
|
class="my-3 size-2.5 shrink-0 rounded-full border-2"
|
|
></div>
|
|
<div
|
|
class="expanding-item"
|
|
:class="{
|
|
expanded: location.clicked,
|
|
}"
|
|
>
|
|
<div class="whitespace-nowrap text-sm">
|
|
<span class="ml-2"> {{ location.name }} </span>
|
|
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import * as THREE from "three";
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
|
|
import { ref, onMounted, onUnmounted } from "vue";
|
|
|
|
const container = ref(null);
|
|
const showLabels = ref(false);
|
|
|
|
const locations = ref([
|
|
// Active locations
|
|
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
|
|
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
|
|
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
|
|
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
|
|
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
|
|
// Future Locations
|
|
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
|
|
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },
|
|
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
|
|
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
|
|
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
|
|
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
|
|
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
|
|
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
|
|
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
|
|
]);
|
|
|
|
const isLocationVisible = (location) => {
|
|
if (!location.screenPosition || !globe) return false;
|
|
|
|
const vector = latLngToVector3(location.lat, location.lng).clone();
|
|
vector.applyMatrix4(globe.matrixWorld);
|
|
|
|
const cameraVector = new THREE.Vector3();
|
|
camera.getWorldPosition(cameraVector);
|
|
|
|
const viewVector = vector.clone().sub(cameraVector).normalize();
|
|
|
|
const normal = vector.clone().normalize();
|
|
|
|
const dotProduct = normal.dot(viewVector);
|
|
|
|
return dotProduct < -0.15;
|
|
};
|
|
|
|
const toggleLocationClicked = (location) => {
|
|
console.log("clicked", location.name);
|
|
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
|
|
};
|
|
|
|
let scene, camera, renderer, globe, controls;
|
|
let animationFrame;
|
|
|
|
const init = () => {
|
|
scene = new THREE.Scene();
|
|
camera = new THREE.PerspectiveCamera(
|
|
45,
|
|
container.value.clientWidth / container.value.clientHeight,
|
|
0.1,
|
|
1000,
|
|
);
|
|
renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
alpha: true,
|
|
powerPreference: "low-power",
|
|
});
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
|
|
container.value.appendChild(renderer.domElement);
|
|
|
|
const geometry = new THREE.SphereGeometry(5, 64, 64);
|
|
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
|
|
outlineTexture.minFilter = THREE.LinearFilter;
|
|
outlineTexture.magFilter = THREE.LinearFilter;
|
|
|
|
const material = new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
outlineTexture: { value: outlineTexture },
|
|
globeColor: { value: new THREE.Color("#ffc69e") },
|
|
},
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform sampler2D outlineTexture;
|
|
uniform vec3 globeColor;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vec4 texColor = texture2D(outlineTexture, vUv);
|
|
|
|
float brightness = max(max(texColor.r, texColor.g), texColor.b);
|
|
gl_FragColor = vec4(globeColor, brightness * 0.8);
|
|
}
|
|
`,
|
|
transparent: true,
|
|
side: THREE.FrontSide,
|
|
});
|
|
|
|
globe = new THREE.Mesh(geometry, material);
|
|
scene.add(globe);
|
|
|
|
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
|
|
const atmosphereMaterial = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
side: THREE.BackSide,
|
|
uniforms: {
|
|
color: { value: new THREE.Color("#fabc89") },
|
|
viewVector: { value: camera.position },
|
|
},
|
|
vertexShader: `
|
|
uniform vec3 viewVector;
|
|
varying float intensity;
|
|
void main() {
|
|
vec3 vNormal = normalize(normalMatrix * normal);
|
|
vec3 vNormel = normalize(normalMatrix * viewVector);
|
|
intensity = pow(0.7 - dot(vNormal, vNormel), 2.0);
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform vec3 color;
|
|
varying float intensity;
|
|
void main() {
|
|
gl_FragColor = vec4(color, intensity * 0.4);
|
|
}
|
|
`,
|
|
});
|
|
|
|
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
|
|
scene.add(atmosphere);
|
|
|
|
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
|
|
scene.add(ambientLight);
|
|
|
|
camera.position.z = 15;
|
|
|
|
controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.05;
|
|
controls.rotateSpeed = 0.3;
|
|
controls.enableZoom = false;
|
|
controls.enablePan = false;
|
|
controls.autoRotate = true;
|
|
controls.autoRotateSpeed = 0.05;
|
|
controls.minPolarAngle = Math.PI * 0.3;
|
|
controls.maxPolarAngle = Math.PI * 0.7;
|
|
|
|
globe.rotation.y = Math.PI * 1.9;
|
|
globe.rotation.x = Math.PI * 0.15;
|
|
};
|
|
|
|
const animate = () => {
|
|
animationFrame = requestAnimationFrame(animate);
|
|
controls.update();
|
|
|
|
locations.value.forEach((location) => {
|
|
const position = latLngToVector3(location.lat, location.lng);
|
|
const vector = position.clone();
|
|
vector.applyMatrix4(globe.matrixWorld);
|
|
|
|
const coords = vector.project(camera);
|
|
const screenPosition = {
|
|
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
|
|
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
|
|
};
|
|
location.screenPosition = screenPosition;
|
|
});
|
|
|
|
renderer.render(scene, camera);
|
|
};
|
|
|
|
const latLngToVector3 = (lat, lng) => {
|
|
const phi = (90 - lat) * (Math.PI / 180);
|
|
const theta = (lng + 180) * (Math.PI / 180);
|
|
const radius = 5;
|
|
|
|
return new THREE.Vector3(
|
|
-radius * Math.sin(phi) * Math.cos(theta),
|
|
radius * Math.cos(phi),
|
|
radius * Math.sin(phi) * Math.sin(theta),
|
|
);
|
|
};
|
|
|
|
const handleResize = () => {
|
|
if (!container.value) return;
|
|
camera.aspect = container.value.clientWidth / container.value.clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
|
|
};
|
|
|
|
onMounted(() => {
|
|
init();
|
|
animate();
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
setTimeout(() => {
|
|
showLabels.value = true;
|
|
}, 1000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (animationFrame) {
|
|
cancelAnimationFrame(animationFrame);
|
|
}
|
|
window.removeEventListener("resize", handleResize);
|
|
if (renderer) {
|
|
renderer.dispose();
|
|
}
|
|
if (container.value) {
|
|
container.value.innerHTML = "";
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
@keyframes pulse {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(217, 84, 27, 0.3);
|
|
}
|
|
70% {
|
|
box-shadow: 0 0 0 4px rgba(217, 78, 27, 0);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(217, 84, 27, 0);
|
|
}
|
|
}
|
|
|
|
.animate-pulse {
|
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
}
|
|
|
|
.center-on-top-left {
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
.expanding-item.expanded {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
@media (hover: hover) {
|
|
.location-button:hover .expanding-item {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.expanding-item {
|
|
display: grid;
|
|
grid-template-columns: 0fr;
|
|
transition: grid-template-columns 0.15s ease-in-out;
|
|
overflow: hidden;
|
|
|
|
> div {
|
|
overflow: hidden;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion) {
|
|
.expanding-item {
|
|
transition: none !important;
|
|
}
|
|
}
|
|
</style>
|