Files
Rocketmc/apps/frontend/src/components/ui/servers/Globe.vue
Cal H. 2aabcf36ee refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
2025-08-14 20:48:38 +00:00

310 lines
8.5 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 { onMounted, onUnmounted, ref } 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('#60fbb5') },
},
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('#56f690') },
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(27, 217, 106, 0.3);
}
70% {
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 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>