import React, { useRef, useEffect } from 'react'; import * as THREE from 'three'; import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import TEAM_LOCATIONS from './TeamGlobeData.js'; import { formatInTimeZone } from 'date-fns-tz'; const GLOBE_RADIUS = 5; const MARKER_HEIGHT = 0.4; const MARKER_RADIUS = 0.08; function latLngToVector3(lat, lng, radius) { const phi = (90 - lat) * (Math.PI / 180); const theta = (lng + 180) * (Math.PI / 180); return new THREE.Vector3( -(radius * Math.sin(phi) * Math.cos(theta)), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta) ); } function createAtmosphere() { const geometry = new THREE.SphereGeometry(GLOBE_RADIUS * 1.05, 64, 64); const material = new THREE.ShaderMaterial({ vertexShader: ` varying vec3 vNormal; void main() { vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` varying vec3 vNormal; void main() { float intensity = pow(0.65 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0); gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) * intensity; } `, blending: THREE.AdditiveBlending, side: THREE.BackSide, transparent: true, }); return new THREE.Mesh(geometry, material); } function createMarker(location) { const group = new THREE.Group(); const surfacePos = latLngToVector3(location.lat, location.lng, GLOBE_RADIUS); const outerPos = latLngToVector3(location.lat, location.lng, GLOBE_RADIUS + MARKER_HEIGHT); const normal = surfacePos.clone().normalize(); // Pin line const lineGeometry = new THREE.BufferGeometry().setFromPoints([surfacePos, outerPos]); const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.6 }); group.add(new THREE.Line(lineGeometry, lineMaterial)); // Pin head sphere const sphereGeometry = new THREE.SphereGeometry(MARKER_RADIUS, 16, 16); const sphereMaterial = new THREE.MeshPhongMaterial({ color: location.color, emissive: location.color, emissiveIntensity: 0.5, }); const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial); sphere.position.copy(outerPos); group.add(sphere); // Glow ring at base const ringGeometry = new THREE.RingGeometry(0.06, 0.12, 32); const ringMaterial = new THREE.MeshBasicMaterial({ color: location.color, transparent: true, opacity: 0.4, side: THREE.DoubleSide, }); const ring = new THREE.Mesh(ringGeometry, ringMaterial); ring.position.copy(surfacePos.clone().add(normal.clone().multiplyScalar(0.01))); ring.lookAt(ring.position.clone().add(normal)); group.add(ring); return { group, labelPosition: outerPos, normal }; } function createLabel(location, index) { const div = document.createElement('div'); div.style.cssText = ` color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 11px; background: rgba(0, 0, 0, 0.75); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; padding: 4px 8px; pointer-events: none; white-space: nowrap; line-height: 1.4; `; div.innerHTML = `
${location.name}
${location.staff} staff
`; const label = new CSS2DObject(div); label.position.copy(latLngToVector3(location.lat, location.lng, GLOBE_RADIUS + MARKER_HEIGHT + 0.15)); return label; } export default function TeamGlobeViewer() { const containerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const container = containerRef.current; const width = container.offsetWidth; const height = Math.min(width, 700); // Scene const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0e17); // Camera const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); camera.position.z = 16; // WebGL renderer const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); // CSS2D renderer for labels const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(width, height); labelRenderer.domElement.style.position = 'absolute'; labelRenderer.domElement.style.top = '0'; labelRenderer.domElement.style.left = '0'; labelRenderer.domElement.style.pointerEvents = 'none'; container.appendChild(labelRenderer.domElement); // Lighting const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(5, 3, 5); scene.add(dirLight); // Globe const globeGroup = new THREE.Group(); scene.add(globeGroup); const textureLoader = new THREE.TextureLoader(); const earthTexture = textureLoader.load('/images/earth_texture.jpg'); earthTexture.colorSpace = THREE.SRGBColorSpace; const globeGeometry = new THREE.SphereGeometry(GLOBE_RADIUS, 64, 64); const globeMaterial = new THREE.MeshPhongMaterial({ map: earthTexture, bumpScale: 0.05, }); const globe = new THREE.Mesh(globeGeometry, globeMaterial); globeGroup.add(globe); // Atmosphere globeGroup.add(createAtmosphere()); // Markers and labels const labels = []; const markerNormals = []; TEAM_LOCATIONS.forEach((location, index) => { const { group, normal } = createMarker(location); globeGroup.add(group); const label = createLabel(location, index); globeGroup.add(label); labels.push(label); markerNormals.push(normal); }); // Mouse interaction state let isDragging = false; let previousMouse = { x: 0, y: 0 }; let rotationVelocity = { x: 0, y: 0 }; let autoRotateSpeed = 0.002; let idleTimer = 0; const onMouseDown = (e) => { isDragging = true; previousMouse = { x: e.clientX, y: e.clientY }; idleTimer = 0; }; const onMouseMove = (e) => { if (!isDragging) return; const deltaX = e.clientX - previousMouse.x; const deltaY = e.clientY - previousMouse.y; rotationVelocity.x = deltaY * 0.005; rotationVelocity.y = deltaX * 0.005; globeGroup.rotation.x += rotationVelocity.x; globeGroup.rotation.y += rotationVelocity.y; previousMouse = { x: e.clientX, y: e.clientY }; idleTimer = 0; }; const onMouseUp = () => { isDragging = false; }; const onWheel = (e) => { e.preventDefault(); camera.position.z = Math.max(9, Math.min(25, camera.position.z + e.deltaY * 0.01)); idleTimer = 0; }; // Touch support const onTouchStart = (e) => { if (e.touches.length === 1) { isDragging = true; previousMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY }; idleTimer = 0; } }; const onTouchMove = (e) => { if (!isDragging || e.touches.length !== 1) return; const deltaX = e.touches[0].clientX - previousMouse.x; const deltaY = e.touches[0].clientY - previousMouse.y; rotationVelocity.x = deltaY * 0.005; rotationVelocity.y = deltaX * 0.005; globeGroup.rotation.x += rotationVelocity.x; globeGroup.rotation.y += rotationVelocity.y; previousMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY }; idleTimer = 0; }; const onTouchEnd = () => { isDragging = false; }; const canvas = renderer.domElement; canvas.addEventListener('mousedown', onMouseDown); canvas.addEventListener('mousemove', onMouseMove); canvas.addEventListener('mouseup', onMouseUp); canvas.addEventListener('mouseleave', onMouseUp); canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('touchstart', onTouchStart, { passive: true }); canvas.addEventListener('touchmove', onTouchMove, { passive: true }); canvas.addEventListener('touchend', onTouchEnd); // Time update interval const timeInterval = setInterval(() => { TEAM_LOCATIONS.forEach((loc, i) => { const el = container.querySelector(`.globe-label-time[data-location-index="${i}"]`); if (el) { el.textContent = formatInTimeZone(new Date(), loc.timezone, 'h:mm:ss a zzz'); } }); }, 1000); // Animation loop let animFrameId; const animate = () => { animFrameId = requestAnimationFrame(animate); // Auto-rotate when idle idleTimer++; if (!isDragging && idleTimer > 120) { globeGroup.rotation.y += autoRotateSpeed; } // Damping if (!isDragging) { rotationVelocity.x *= 0.95; rotationVelocity.y *= 0.95; globeGroup.rotation.x += rotationVelocity.x; globeGroup.rotation.y += rotationVelocity.y; } // Label culling: hide labels on far side of globe const cameraDir = new THREE.Vector3(); camera.getWorldDirection(cameraDir); labels.forEach((label, i) => { const markerWorldPos = new THREE.Vector3(); label.getWorldPosition(markerWorldPos); const toMarker = markerWorldPos.clone().sub(camera.position).normalize(); const markerNormalWorld = markerNormals[i].clone().applyQuaternion(globeGroup.quaternion).normalize(); const dot = markerNormalWorld.dot(toMarker); label.visible = dot < -0.1; }); renderer.render(scene, camera); labelRenderer.render(scene, camera); }; animate(); // Trigger initial time update TEAM_LOCATIONS.forEach((loc, i) => { const el = container.querySelector(`.globe-label-time[data-location-index="${i}"]`); if (el) { el.textContent = formatInTimeZone(new Date(), loc.timezone, 'h:mm:ss a zzz'); } }); // Resize handler const onResize = () => { const w = container.offsetWidth; const h = Math.min(w, 700); camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); labelRenderer.setSize(w, h); }; window.addEventListener('resize', onResize); // Cleanup return () => { cancelAnimationFrame(animFrameId); clearInterval(timeInterval); window.removeEventListener('resize', onResize); canvas.removeEventListener('mousedown', onMouseDown); canvas.removeEventListener('mousemove', onMouseMove); canvas.removeEventListener('mouseup', onMouseUp); canvas.removeEventListener('mouseleave', onMouseUp); canvas.removeEventListener('wheel', onWheel); canvas.removeEventListener('touchstart', onTouchStart); canvas.removeEventListener('touchmove', onTouchMove); canvas.removeEventListener('touchend', onTouchEnd); renderer.dispose(); if (container.contains(renderer.domElement)) container.removeChild(renderer.domElement); if (container.contains(labelRenderer.domElement)) container.removeChild(labelRenderer.domElement); }; }, []); return (
); }