Skip to content

Instantly share code, notes, and snippets.

@mdsumner
Created January 16, 2026 05:07
Show Gist options
  • Select an option

  • Save mdsumner/bd05596c0bf6355cbc2770f04d63632b to your computer and use it in GitHub Desktop.

Select an option

Save mdsumner/bd05596c0bf6355cbc2770f04d63632b to your computer and use it in GitHub Desktop.
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