ez_geodesics / index.html
Molbap's picture
Molbap HF Staff
no
bf54c19 verified
<!DOCTYPE html>
<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.cos1)*Math.cos2)*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.tan1));
const U2 = Math.atan((1-f) * Math.tan2));
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>