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/[email protected]/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); | |
| // Borders | |
| fetch('https://unpkg.com/three-globe/example/datasets/ne_110m_admin_0_countries.geojson') | |
| .then(r => r.json()) | |
| .then(({ features }) => { | |
| globe | |
| .polygonsData(features) | |
| .polygonCapColor(() => 'rgba(0,0,0,0)') // transparent fill | |
| .polygonSideColor(() => 'rgba(0,0,0,0)') // no sides | |
| .polygonStrokeColor(() => 'rgba(255,255,255,0.25)') | |
| .polygonAltitude(() => 0.002); // small lift to avoid z-fighting | |
| }) | |
| .catch(() => {/* ignore */}); | |
| // 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> | |