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 (
);
}