File size: 13,578 Bytes
151cac5
 
 
 
 
 
 
052e8bf
 
f2f7613
 
 
 
 
 
 
 
 
6bac0f9
f2f7613
151cac5
052e8bf
f2f7613
 
151cac5
 
 
f2f7613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bac0f9
052e8bf
151cac5
052e8bf
 
ca1f4ae
8f64946
052e8bf
 
 
 
 
151cac5
 
f2f7613
151cac5
 
 
 
 
 
 
 
 
f2f7613
151cac5
 
f2f7613
 
 
151cac5
 
f2f7613
151cac5
 
 
 
 
 
f2f7613
151cac5
f2f7613
 
151cac5
f2f7613
151cac5
f2f7613
151cac5
f2f7613
 
 
 
151cac5
 
052e8bf
f2f7613
 
 
 
052e8bf
8f64946
ca1f4ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bac0f9
ca1f4ae
 
052e8bf
 
 
 
 
 
6bac0f9
052e8bf
6bac0f9
052e8bf
6bac0f9
052e8bf
 
ca1f4ae
f2f7613
6bac0f9
 
 
 
 
 
 
 
 
 
052e8bf
6bac0f9
 
 
052e8bf
 
 
 
 
 
 
 
f2f7613
6bac0f9
 
 
 
 
 
 
 
 
 
 
052e8bf
6bac0f9
 
 
 
052e8bf
 
 
6bac0f9
052e8bf
6bac0f9
052e8bf
6bac0f9
 
 
052e8bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151cac5
 
ca1f4ae
052e8bf
151cac5
 
25f00cc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
<!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>