Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>Geodesic Distance Globe</title> | |
<style> | |
html, body { height: 100%; margin: 0; background:#0b0f19; color:#e5e7eb; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, "Helvetica Neue", Arial; } | |
#globe-container { width: 100vw; height: 100vh; } | |
.ui { position: fixed; inset: 12px auto auto 12px; z-index: 10; background: rgba(17,24,39,.7); backdrop-filter: blur(6px); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:12px 14px; box-shadow:0 6px 24px rgba(0,0,0,.25); user-select:none; } | |
.row{ display:flex; align-items:center; gap:10px; margin-top:6px; flex-wrap:wrap; } | |
.row:first-child{ margin-top:0; } | |
.seg{ display:flex; border:1px solid rgba(255,255,255,.15); border-radius:10px; overflow:hidden; } | |
.seg button{ border:0; background:transparent; padding:6px 10px; color:#cbd5e1; cursor:pointer; } | |
.seg button.active{ background: rgba(255,255,255,.12); color:#fff; } | |
.btn{ appearance:none; border:1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.05); color:#e5e7eb; border-radius:10px; padding:6px 10px; cursor:pointer; } | |
.btn:hover{ background: rgba(255,255,255,.12); } | |
.badge{ display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:999px; border:1px solid rgba(255,255,255,.12); } | |
.dot{ width:12px; height:12px; border-radius:999px; display:inline-block; } | |
.muted{ color:#93a3b3; } | |
.footer { position: fixed; right: 12px; bottom: 12px; z-index: 10; font-size: 12px; color:#94a3b8; } | |
.error { position: fixed; left: 12px; bottom: 12px; z-index: 20; background:#7f1d1d; color:#fecaca; border:1px solid #ef4444; border-radius:8px; padding:8px 10px; display:none; max-width: 70vw; } | |
a { color:#93c5fd; text-decoration:none; } | |
a:hover{ text-decoration:underline; } | |
</style> | |
</head> | |
<body> | |
<div id="globe-container"></div> | |
<div class="ui" id="panel"> | |
<div class="row"> | |
<span class="badge" title="Active endpoint"> | |
<span class="dot" id="dotA" style="background:#60a5fa"></span>A | |
<span class="dot" id="dotB" style="background:#334155; margin-left:8px"></span>B | |
</span> | |
<div class="seg" role="tablist" aria-label="Active endpoint"> | |
<button id="setA" class="active">Set A</button> | |
<button id="setB">Set B</button> | |
</div> | |
<div class="seg" role="tablist" aria-label="Units"> | |
<button data-unit="km" class="active">km</button> | |
<button data-unit="nm">nm</button> | |
</div> | |
<button class="btn" id="swapBtn" title="Swap A ↔ B">Swap</button> | |
<button class="btn" id="randBtn" title="Randomize both">Random</button> | |
<button class="btn" id="copyBtn" title="Copy shareable URL">Copy URL</button> | |
</div> | |
<div class="row muted" id="coords"></div> | |
<div class="row" id="distance"></div> | |
</div> | |
<div class="footer">Drag to rotate. Scroll to zoom. Click (no drag) to set point. Drag markers to move.</div> | |
<div class="error" id="err"></div> | |
<noscript style="color:#fff; position:fixed; left:12px; bottom:48px;">Enable JavaScript.</noscript> | |
<!-- Required scripts (UMD) --> | |
<script src="https://unpkg.com/three@0.160.0/build/three.min.js" defer></script> | |
<script src="https://unpkg.com/globe.gl" defer></script> | |
<script> | |
(function(){ | |
const showErr = (msg) => { const el = document.getElementById('err'); el.textContent = msg; el.style.display = 'block'; }; | |
const toRad = (deg) => deg * Math.PI / 180; | |
const R_EARTH = 6371008.8; // meters | |
function haversineMeters(lat1, lon1, lat2, lon2) { | |
const φ1 = toRad(lat1), φ2 = toRad(lat2); | |
const Δφ = toRad(lat2 - lat1); | |
const Δλ = toRad(lon2 - lon1); | |
const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2; | |
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); | |
return R_EARTH * c; | |
} | |
function vincentyWGS84Meters(lat1, lon1, lat2, lon2) { | |
const a = 6378137.0, f = 1/298.257223563, b = 6356752.314245; | |
const φ1 = toRad(lat1), φ2 = toRad(lat2); | |
const L = toRad(lon2 - lon1); | |
if (Math.abs(L) < 1e-20 && Math.abs(lat1 - lat2) < 1e-12) return { distance: 0, converged: true, iterations: 0 }; | |
const U1 = Math.atan((1-f) * Math.tan(φ1)); | |
const U2 = Math.atan((1-f) * Math.tan(φ2)); | |
const sinU1 = Math.sin(U1), cosU1 = Math.cos(U1); | |
const sinU2 = Math.sin(U2), cosU2 = Math.cos(U2); | |
let λ = L, λPrev, sinσ, cosσ, σ, sinα, cos2αm; let iter=0; | |
do { | |
const sinλ = Math.sin(λ), cosλ = Math.cos(λ); | |
sinσ = Math.sqrt((cosU2*sinλ)**2 + (cosU1*sinU2 - sinU1*cosU2*cosλ)**2); | |
if (sinσ === 0) return { distance: 0, converged: true, iterations: iter }; | |
cosσ = sinU1*sinU2 + cosU1*cosU2*cosλ; | |
σ = Math.atan2(sinσ, cosσ); | |
sinα = (cosU1*cosU2*sinλ) / sinσ; | |
const cos2α = 1 - sinα**2; | |
cos2αm = cos2α === 0 ? 0 : (cosσ - (2*sinU1*sinU2)/cos2α); | |
const C = (f/16)*cos2α*(4 + f*(4 - 3*cos2α)); | |
λPrev = λ; | |
λ = L + (1 - C)*f*sinα*(σ + C*sinσ*(cos2αm + C*cosσ*(-1 + 2*cos2αm**2))); | |
} while (Math.abs(λ - λPrev) > 1e-12 && ++iter < 200); | |
if (iter >= 200) return { distance: haversineMeters(lat1, lon1, lat2, lon2), converged: false, iterations: iter }; | |
const u2 = (1 - sinα**2) * (a*a - b*b) / (b*b); | |
const A = 1 + (u2/16384)*(4096 + u2*(-768 + u2*(320 - 175*u2))); | |
const B = (u2/1024)*(256 + u2*(-128 + u2*(74 - 47*u2))); | |
const Δσ = B*sinσ*(cos2αm + (B/4)*(cosσ*(-1 + 2*cos2αm**2) - (B/6)*cos2αm*(-3 + 4*sinσ**2)*(-3 + 4*cos2αm**2))); | |
const s = b*A*(σ - Δσ); | |
return { distance: s, converged: true, iterations: iter }; | |
} | |
function formatDistance(meters, unit='km') { let val = meters; if (unit==='km') val/=1000; else if (unit==='mi') val/=1609.344; else if (unit==='nm') val/=1852; const d = val>=100?0:val>=10?1:2; return `${val.toFixed(d)} ${unit}`; } | |
const clampLat = (lat) => Math.max(-89.9999, Math.min(89.9999, lat)); | |
const normLng = (lng) => ((lng + 180) % 360 + 360) % 360 - 180; | |
const parseParamCoord = (s) => { if (!s) return null; const [a,b] = s.split(','); const lat=Number(a), lng=Number(b); return (Number.isFinite(lat)&&Number.isFinite(lng))?{lat:clampLat(lat),lng:normLng(lng)}:null; }; | |
function boot() { | |
if (!window.THREE) { showErr('three.js failed to load.'); return; } | |
if (!window.Globe || typeof window.Globe !== 'function') { showErr('globe.gl failed to load.'); return; } | |
// State | |
const url = new URL(location.href); | |
let pointA = parseParamCoord(url.searchParams.get('a')) ?? { lat: 48.8566, lng: 2.3522 }; | |
let pointB = parseParamCoord(url.searchParams.get('b')) ?? { lat: 40.7128, lng: -74.0060 }; | |
let active = 'A'; | |
let unit = url.searchParams.get('unit') || 'km'; | |
// UI wiring | |
const dotA = document.getElementById('dotA'); | |
const dotB = document.getElementById('dotB'); | |
const setA = document.getElementById('setA'); | |
const setB = document.getElementById('setB'); | |
const coordsEl = document.getElementById('coords'); | |
const distEl = document.getElementById('distance'); | |
const swapBtn = document.getElementById('swapBtn'); | |
const randBtn = document.getElementById('randBtn'); | |
const copyBtn = document.getElementById('copyBtn'); | |
document.querySelectorAll('[data-unit]').forEach(btn => btn.addEventListener('click', () => { unit = btn.dataset.unit; document.querySelectorAll('[data-unit]').forEach(b=>b.classList.toggle('active', b===btn)); updateScene(); })); | |
setA.onclick = () => { active='A'; setA.classList.add('active'); setB.classList.remove('active'); dotA.style.background='#60a5fa'; dotB.style.background='#334155'; }; | |
setB.onclick = () => { active='B'; setB.classList.add('active'); setA.classList.remove('active'); dotB.style.background='#a78bfa'; dotA.style.background='#334155'; }; | |
swapBtn.onclick = () => { const t = pointA; pointA = pointB; pointB = t; updateScene(); }; | |
randBtn.onclick = () => { const rnd = () => ({ lat: Math.random()*180-90, lng: Math.random()*360-180 }); pointA=rnd(); pointB=rnd(); updateScene(); }; | |
copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(location.href); } catch(e){} }; | |
// Globe mount | |
const container = document.getElementById('globe-container'); | |
const globe = window.Globe()(container) | |
.backgroundColor('rgba(5,7,12,1)') | |
.showAtmosphere(true) | |
.globeImageUrl('https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg') | |
.bumpImageUrl('https://unpkg.com/three-globe/example/img/earth-topology.png') | |
.backgroundImageUrl('https://unpkg.com/three-globe/example/img/night-sky.png') | |
.labelAltitude(() => 0.01) | |
.labelSize(() => 1.6) | |
.labelIncludeDot(true) | |
.labelDotRadius(1.0) | |
.arcAltitude(() => 0.2) | |
.arcStroke(() => 0.8) | |
.arcDashLength(() => 0.6) | |
.arcDashGap(() => 0.2) | |
.arcDashAnimateTime(() => 2000); | |
// Orbit controls tuning (zoom/rotate) | |
const ctrl = globe.controls(); | |
ctrl.enableDamping = true; | |
ctrl.dampingFactor = 0.08; | |
ctrl.rotateSpeed = 0.4; | |
ctrl.zoomSpeed = 0.7; | |
ctrl.minDistance = 150; | |
ctrl.maxDistance = 800; | |
// Interaction: drag markers or click (without drag) to set active point | |
const canvas = globe.renderer().domElement; | |
let dragId = null; const HIT_R_SQ = 26*26; // larger hit area | |
let downX=0, downY=0, moved=false; // distinguish click vs rotate-drag | |
function pickMarkerAt(x, y) { | |
const a = globe.getScreenCoords(pointA.lat, pointA.lng); | |
const b = globe.getScreenCoords(pointB.lat, pointB.lng); | |
const da = (a.x-x)*(a.x-x)+(a.y-y)*(a.y-y); | |
const db = (b.x-x)*(b.x-x)+(b.y-y)*(b.y-y); | |
if (da <= HIT_R_SQ && db <= HIT_R_SQ) return active; if (da <= HIT_R_SQ) return 'A'; if (db <= HIT_R_SQ) return 'B'; return null; | |
} | |
function toGeo(evt){ return globe.toGlobeCoords(evt.clientX, evt.clientY); } | |
function updateCursor(x,y){ | |
const over = !!pickMarkerAt(x,y); | |
canvas.style.cursor = over ? 'pointer' : ''; | |
} | |
canvas.addEventListener('pointerdown', (evt) => { | |
dragId = pickMarkerAt(evt.clientX, evt.clientY); | |
downX = evt.clientX; downY = evt.clientY; moved = false; | |
updateCursor(evt.clientX, evt.clientY); | |
}); | |
window.addEventListener('pointermove', (evt) => { | |
const dx = evt.clientX - downX, dy = evt.clientY - downY; | |
if (!moved && (dx*dx + dy*dy) > 9) moved = true; // >3px considered a drag | |
if (!dragId) { updateCursor(evt.clientX, evt.clientY); return; } | |
const pos = toGeo(evt); if (!pos) return; const { lat, lng } = pos; | |
if (dragId==='A') pointA = { lat: clampLat(lat), lng: normLng(lng) }; else pointB = { lat: clampLat(lat), lng: normLng(lng) }; | |
updateScene(true); | |
}); | |
window.addEventListener('pointerup', () => { dragId = null; updateScene(); }); | |
canvas.addEventListener('click', (evt) => { | |
if (dragId) return; // ended a marker drag | |
if (moved) return; // it was a globe rotation drag | |
const pos = toGeo(evt); if (!pos) return; const { lat, lng } = pos; | |
if (active==='A') pointA = { lat: clampLat(lat), lng: normLng(lng) }; else pointB = { lat: clampLat(lat), lng: normLng(lng) }; | |
updateScene(); | |
}); | |
window.addEventListener('resize', () => { globe.width(window.innerWidth); globe.height(window.innerHeight); }); | |
function updateURL() { | |
const u = new URL(location.href); | |
u.searchParams.set('a', `${pointA.lat.toFixed(6)},${pointA.lng.toFixed(6)}`); | |
u.searchParams.set('b', `${pointB.lat.toFixed(6)},${pointB.lng.toFixed(6)}`); | |
u.searchParams.set('unit', unit); | |
history.replaceState(null, '', u); | |
} | |
function updateScene(light=false) { | |
globe | |
.labelsData([ | |
{ id:'A', lat: pointA.lat, lng: pointA.lng, text:'A', color:'#60a5fa' }, | |
{ id:'B', lat: pointB.lat, lng: pointB.lng, text:'B', color:'#a78bfa' } | |
]) | |
.labelLat(d=>d.lat).labelLng(d=>d.lng).labelText(d=>d.text).labelColor(d=>d.color) | |
.arcsData([{ startLat: pointA.lat, startLng: pointA.lng, endLat: pointB.lat, endLng: pointB.lng, color: ['#60a5fa','#a78bfa'] }]) | |
.arcStartLat(d=>d.startLat).arcStartLng(d=>d.startLng).arcEndLat(d=>d.endLat).arcEndLng(d=>d.endLng).arcColor(d=>d.color); | |
const v = vincentyWGS84Meters(pointA.lat, pointA.lng, pointB.lat, pointB.lng).distance; | |
const h = haversineMeters(pointA.lat, pointA.lng, pointB.lat, pointB.lng); | |
coordsEl.textContent = `A: ${pointA.lat.toFixed(4)}, ${pointA.lng.toFixed(4)} · B: ${pointB.lat.toFixed(4)}, ${pointB.lng.toFixed(4)}`; | |
distEl.innerHTML = `WGS-84 (Vincenty): <strong>${formatDistance(v, unit)}</strong> <span class="muted">(Spherical: ${formatDistance(h, unit)})</span>`; | |
if (!light) updateURL(); | |
} | |
document.querySelectorAll('[data-unit]').forEach(b => b.classList.toggle('active', b.dataset.unit===unit)); | |
updateScene(); | |
} | |
window.addEventListener('load', boot); | |
})(); | |
</script> | |
</body> | |
</html> | |