Created
January 16, 2026 05:07
-
-
Save mdsumner/bd05596c0bf6355cbc2770f04d63632b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useRef, useEffect, useState, useCallback } from 'react'; | |
| import * as THREE from 'three'; | |
| // Simplified world countries - key ones for demonstration | |
| const COUNTRIES_URL = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson'; | |
| export default function GlobeComparison() { | |
| const containerRef = useRef(null); | |
| const sceneRef = useRef(null); | |
| const cameraRef = useRef(null); | |
| const rendererRef = useRef(null); | |
| const globeRef = useRef(null); | |
| const countriesRef = useRef([]); | |
| const selectedMeshRef = useRef(null); | |
| const copiedMeshRef = useRef(null); | |
| const raycasterRef = useRef(new THREE.Raycaster()); | |
| const mouseRef = useRef(new THREE.Vector2()); | |
| const [countries, setCountries] = useState([]); | |
| const [selectedCountry, setSelectedCountry] = useState(null); | |
| const [viewCenter, setViewCenter] = useState({ lat: 0, lon: 0 }); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [lastMouse, setLastMouse] = useState({ x: 0, y: 0 }); | |
| const [rotation, setRotation] = useState({ x: 0, y: 0 }); | |
| const [loading, setLoading] = useState(true); | |
| // Convert lat/lon to 3D position on sphere | |
| const latLonToVector3 = useCallback((lat, lon, radius = 1) => { | |
| const phi = (90 - lat) * (Math.PI / 180); | |
| const theta = (lon + 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) | |
| ); | |
| }, []); | |
| // Get center of view from camera position | |
| const getCameraCenter = useCallback(() => { | |
| if (!cameraRef.current) return { lat: 0, lon: 0 }; | |
| const pos = cameraRef.current.position.clone().normalize(); | |
| const lat = 90 - Math.acos(pos.y) * (180 / Math.PI); | |
| const lon = Math.atan2(pos.z, -pos.x) * (180 / Math.PI) - 180; | |
| return { lat, lon: ((lon + 540) % 360) - 180 }; | |
| }, []); | |
| // Create country outline mesh from coordinates | |
| const createCountryMesh = useCallback((coords, color = 0x44aa88, offsetLat = 0, offsetLon = 0) => { | |
| const group = new THREE.Group(); | |
| const processRing = (ring) => { | |
| const points = []; | |
| for (let i = 0; i < ring.length; i++) { | |
| let [lon, lat] = ring[i]; | |
| lat += offsetLat; | |
| lon += offsetLon; | |
| // Clamp latitude | |
| lat = Math.max(-89.9, Math.min(89.9, lat)); | |
| points.push(latLonToVector3(lat, lon, 1.001)); | |
| } | |
| return points; | |
| }; | |
| const createLineFromPoints = (points) => { | |
| if (points.length < 2) return null; | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| const material = new THREE.LineBasicMaterial({ color, linewidth: 2 }); | |
| return new THREE.Line(geometry, material); | |
| }; | |
| // Handle different geometry types | |
| const processPolygon = (polygon) => { | |
| polygon.forEach(ring => { | |
| const points = processRing(ring); | |
| const line = createLineFromPoints(points); | |
| if (line) group.add(line); | |
| }); | |
| }; | |
| if (coords.type === 'Polygon') { | |
| processPolygon(coords.coordinates); | |
| } else if (coords.type === 'MultiPolygon') { | |
| coords.coordinates.forEach(polygon => processPolygon(polygon)); | |
| } | |
| return group; | |
| }, [latLonToVector3]); | |
| // Calculate country centroid | |
| const getCountryCentroid = useCallback((geometry) => { | |
| let totalLat = 0, totalLon = 0, count = 0; | |
| const processRing = (ring) => { | |
| ring.forEach(([lon, lat]) => { | |
| totalLat += lat; | |
| totalLon += lon; | |
| count++; | |
| }); | |
| }; | |
| const processPolygon = (polygon) => { | |
| polygon.forEach(ring => processRing(ring)); | |
| }; | |
| if (geometry.type === 'Polygon') { | |
| processPolygon(geometry.coordinates); | |
| } else if (geometry.type === 'MultiPolygon') { | |
| geometry.coordinates.forEach(polygon => processPolygon(polygon)); | |
| } | |
| return { lat: totalLat / count, lon: totalLon / count }; | |
| }, []); | |
| // Load countries | |
| useEffect(() => { | |
| fetch(COUNTRIES_URL) | |
| .then(res => res.json()) | |
| .then(data => { | |
| setCountries(data.features.map(f => ({ | |
| name: f.properties.NAME || f.properties.ADMIN, | |
| geometry: f.geometry, | |
| centroid: getCountryCentroid(f.geometry) | |
| }))); | |
| setLoading(false); | |
| }) | |
| .catch(err => { | |
| console.error('Failed to load countries:', err); | |
| setLoading(false); | |
| }); | |
| }, [getCountryCentroid]); | |
| // Initialize Three.js scene | |
| useEffect(() => { | |
| if (!containerRef.current) return; | |
| // Scene | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x111122); | |
| sceneRef.current = scene; | |
| // Camera | |
| const camera = new THREE.PerspectiveCamera( | |
| 45, | |
| containerRef.current.clientWidth / containerRef.current.clientHeight, | |
| 0.1, | |
| 100 | |
| ); | |
| camera.position.set(0, 0, 3); | |
| cameraRef.current = camera; | |
| // Renderer | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| containerRef.current.appendChild(renderer.domElement); | |
| rendererRef.current = renderer; | |
| // Globe sphere | |
| const globeGeometry = new THREE.SphereGeometry(1, 64, 64); | |
| const globeMaterial = new THREE.MeshBasicMaterial({ | |
| color: 0x1a1a2e, | |
| transparent: true, | |
| opacity: 0.9 | |
| }); | |
| const globe = new THREE.Mesh(globeGeometry, globeMaterial); | |
| scene.add(globe); | |
| globeRef.current = globe; | |
| // Graticule (lat/lon lines) | |
| const graticuleMaterial = new THREE.LineBasicMaterial({ | |
| color: 0x333355, | |
| transparent: true, | |
| opacity: 0.5 | |
| }); | |
| // Latitude lines | |
| for (let lat = -80; lat <= 80; lat += 20) { | |
| const points = []; | |
| for (let lon = -180; lon <= 180; lon += 5) { | |
| const phi = (90 - lat) * (Math.PI / 180); | |
| const theta = (lon + 180) * (Math.PI / 180); | |
| points.push(new THREE.Vector3( | |
| -1.001 * Math.sin(phi) * Math.cos(theta), | |
| 1.001 * Math.cos(phi), | |
| 1.001 * Math.sin(phi) * Math.sin(theta) | |
| )); | |
| } | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| scene.add(new THREE.Line(geometry, graticuleMaterial)); | |
| } | |
| // Longitude lines | |
| for (let lon = -180; lon < 180; lon += 30) { | |
| const points = []; | |
| for (let lat = -90; lat <= 90; lat += 5) { | |
| const phi = (90 - lat) * (Math.PI / 180); | |
| const theta = (lon + 180) * (Math.PI / 180); | |
| points.push(new THREE.Vector3( | |
| -1.001 * Math.sin(phi) * Math.cos(theta), | |
| 1.001 * Math.cos(phi), | |
| 1.001 * Math.sin(phi) * Math.sin(theta) | |
| )); | |
| } | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| scene.add(new THREE.Line(geometry, graticuleMaterial)); | |
| } | |
| // Animation loop | |
| const animate = () => { | |
| requestAnimationFrame(animate); | |
| renderer.render(scene, camera); | |
| }; | |
| animate(); | |
| // Handle resize | |
| const handleResize = () => { | |
| if (!containerRef.current) return; | |
| camera.aspect = containerRef.current.clientWidth / containerRef.current.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight); | |
| }; | |
| window.addEventListener('resize', handleResize); | |
| return () => { | |
| window.removeEventListener('resize', handleResize); | |
| renderer.dispose(); | |
| containerRef.current?.removeChild(renderer.domElement); | |
| }; | |
| }, []); | |
| // Add countries to scene when loaded | |
| useEffect(() => { | |
| if (!sceneRef.current || countries.length === 0) return; | |
| // Clear existing country meshes | |
| countriesRef.current.forEach(mesh => sceneRef.current.remove(mesh)); | |
| countriesRef.current = []; | |
| // Add country outlines | |
| countries.forEach((country, idx) => { | |
| const mesh = createCountryMesh(country.geometry, 0x44aa88); | |
| mesh.userData = { countryIndex: idx, name: country.name }; | |
| sceneRef.current.add(mesh); | |
| countriesRef.current.push(mesh); | |
| }); | |
| }, [countries, createCountryMesh]); | |
| // Handle mouse interactions | |
| const handleMouseDown = (e) => { | |
| setIsDragging(true); | |
| setLastMouse({ x: e.clientX, y: e.clientY }); | |
| }; | |
| const handleMouseMove = (e) => { | |
| if (!isDragging || !cameraRef.current) return; | |
| const deltaX = e.clientX - lastMouse.x; | |
| const deltaY = e.clientY - lastMouse.y; | |
| setRotation(prev => ({ | |
| x: prev.x + deltaY * 0.005, | |
| y: prev.y + deltaX * 0.005 | |
| })); | |
| // Rotate camera around globe | |
| const radius = 3; | |
| const newRotY = rotation.y + deltaX * 0.005; | |
| const newRotX = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, rotation.x + deltaY * 0.005)); | |
| cameraRef.current.position.x = radius * Math.sin(newRotY) * Math.cos(newRotX); | |
| cameraRef.current.position.y = radius * Math.sin(newRotX); | |
| cameraRef.current.position.z = radius * Math.cos(newRotY) * Math.cos(newRotX); | |
| cameraRef.current.lookAt(0, 0, 0); | |
| setLastMouse({ x: e.clientX, y: e.clientY }); | |
| setViewCenter(getCameraCenter()); | |
| }; | |
| const handleMouseUp = () => { | |
| setIsDragging(false); | |
| }; | |
| const handleClick = (e) => { | |
| if (!containerRef.current || !cameraRef.current || !sceneRef.current) return; | |
| if (Math.abs(e.clientX - lastMouse.x) > 5 || Math.abs(e.clientY - lastMouse.y) > 5) return; | |
| const rect = containerRef.current.getBoundingClientRect(); | |
| mouseRef.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; | |
| mouseRef.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; | |
| raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current); | |
| const intersects = raycasterRef.current.intersectObjects(countriesRef.current, true); | |
| if (intersects.length > 0) { | |
| let obj = intersects[0].object; | |
| while (obj.parent && !obj.userData.name) { | |
| obj = obj.parent; | |
| } | |
| if (obj.userData.name) { | |
| selectCountry(obj.userData.countryIndex); | |
| } | |
| } | |
| }; | |
| const selectCountry = useCallback((index) => { | |
| if (!sceneRef.current || index < 0 || index >= countries.length) return; | |
| const country = countries[index]; | |
| setSelectedCountry(country.name); | |
| // Remove previous selection highlight | |
| if (selectedMeshRef.current) { | |
| sceneRef.current.remove(selectedMeshRef.current); | |
| } | |
| if (copiedMeshRef.current) { | |
| sceneRef.current.remove(copiedMeshRef.current); | |
| } | |
| // Highlight selected country | |
| const selectedMesh = createCountryMesh(country.geometry, 0xffaa00); | |
| sceneRef.current.add(selectedMesh); | |
| selectedMeshRef.current = selectedMesh; | |
| // Get current view center | |
| const center = getCameraCenter(); | |
| // Calculate offset to move country to view center | |
| const offsetLat = center.lat - country.centroid.lat; | |
| const offsetLon = center.lon - country.centroid.lon; | |
| // Create copied country at view center | |
| const copiedMesh = createCountryMesh(country.geometry, 0xff4444, offsetLat, offsetLon); | |
| sceneRef.current.add(copiedMesh); | |
| copiedMeshRef.current = copiedMesh; | |
| }, [countries, createCountryMesh, getCameraCenter]); | |
| // Update copied mesh position when view changes | |
| useEffect(() => { | |
| if (!selectedCountry || !sceneRef.current || !copiedMeshRef.current) return; | |
| const countryIdx = countries.findIndex(c => c.name === selectedCountry); | |
| if (countryIdx < 0) return; | |
| const country = countries[countryIdx]; | |
| // Remove old copied mesh | |
| sceneRef.current.remove(copiedMeshRef.current); | |
| // Create new one at current view center | |
| const center = getCameraCenter(); | |
| const offsetLat = center.lat - country.centroid.lat; | |
| const offsetLon = center.lon - country.centroid.lon; | |
| const copiedMesh = createCountryMesh(country.geometry, 0xff4444, offsetLat, offsetLon); | |
| sceneRef.current.add(copiedMesh); | |
| copiedMeshRef.current = copiedMesh; | |
| }, [viewCenter, selectedCountry, countries, createCountryMesh, getCameraCenter]); | |
| return ( | |
| <div className="flex flex-col h-screen bg-gray-900 text-white"> | |
| <div className="p-4 bg-gray-800 border-b border-gray-700"> | |
| <h1 className="text-xl font-bold mb-2">Globe Size Comparison</h1> | |
| <p className="text-sm text-gray-400 mb-2"> | |
| Click a country to select it (orange). It will be copied to your current view center (red) | |
| showing its true proportional size. Drag to rotate the globe. | |
| </p> | |
| <div className="flex gap-4 text-sm"> | |
| <span className="flex items-center gap-2"> | |
| <span className="w-4 h-1 bg-green-500 inline-block"></span> Countries | |
| </span> | |
| <span className="flex items-center gap-2"> | |
| <span className="w-4 h-1 bg-orange-500 inline-block"></span> Selected | |
| </span> | |
| <span className="flex items-center gap-2"> | |
| <span className="w-4 h-1 bg-red-500 inline-block"></span> Comparison copy | |
| </span> | |
| </div> | |
| {selectedCountry && ( | |
| <p className="mt-2 text-yellow-400">Selected: {selectedCountry}</p> | |
| )} | |
| <p className="text-xs text-gray-500 mt-1"> | |
| View center: {viewCenter.lat.toFixed(1)}°, {viewCenter.lon.toFixed(1)}° | |
| </p> | |
| </div> | |
| <div | |
| ref={containerRef} | |
| className="flex-1 cursor-grab active:cursor-grabbing" | |
| onMouseDown={handleMouseDown} | |
| onMouseMove={handleMouseMove} | |
| onMouseUp={handleMouseUp} | |
| onMouseLeave={handleMouseUp} | |
| onClick={handleClick} | |
| > | |
| {loading && ( | |
| <div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80"> | |
| <p className="text-lg">Loading countries...</p> | |
| </div> | |
| )} | |
| </div> | |
| <div className="p-2 bg-gray-800 border-t border-gray-700 text-xs text-gray-500"> | |
| Data: Natural Earth | Tip: Select Greenland, then rotate to view it over Africa or Australia | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment