1/* Forecast Bar's opt-in ephemeris explorer. No WebGL, vendor download, or 2 ephemeris request happens until the visitor activates the scene. */ 3(() => { 4 'use strict'; 5 const root = document.querySelector('[data-astronomy-scene]'); 6 if (!root) return; 7 const $ = (name) => root.querySelector(`[data-scene-${name}]`); 8 const stage = $('stage'); 9 const status = $('status'); 10 const dateInput = $('date'); 11 const scrub = $('scrub'); 12 const playButton = $('play'); 13 const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 14 const colors = [0xb6afa3, 0xe1bb85, 0x6099e8, 0xc66c49, 0xd8b18c, 0xe8cc96, 0x7dc7d3, 0x5575d0]; 15 const sizes = [.16, .22, .24, .19, .55, .46, .34, .33]; 16 const names = ['Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune']; 17 let THREE, OrbitControls, materials, renderer, scene, camera, solarCamera, moonCamera, controls, solar, sunMarker, moonGroup, moonMesh, moonLight; 18 let moonTextureRequested = false; 19 let bodies = [], tracks = [], labels = [], yearData, day = 0, active = false, loading = false; 20 let mode = root.dataset.sceneInitialMode || 'solar', transition, tourTimer, tourRunning = false, ruler, playing = false, timer, raf = 0, resizeObserver, fetchController, generation = 0; 21 const vector = (xyz) => new THREE.Vector3(xyz[0], xyz[2], -xyz[1]); // Ecliptic north points up. 22 const magnitude = (xyz) => Math.hypot(...xyz); 23 const currentFrame = () => yearData.frames[day]; 24 const sceneViewport = () => { 25 const width = Math.max(stage.clientWidth, 1), height = Math.max(stage.clientHeight, 1); 26 const bottom = width < 650 ? Math.min(145, height * .36) : 0; 27 return { width, height, bottom, usableHeight: height - bottom }; 28 }; 29 30 if (navigator.connection?.saveData) { 31 $('download').textContent = 'Data Saver is on. Activate only if you want to download the 3D viewer and its annual position data.'; 32 } 33 if (reducedMotion.matches) playButton.textContent = 'Watch the Moon change (optional)'; 34 35 function projectedPosition(xyz) { 36 const position = vector(xyz); 37 if ($('scale').value === 'compressed') { 38 const distance = position.length(); 39 if (distance) position.multiplyScalar((4 * Math.log1p(distance)) / distance); 40 } 41 return position; 42 } 43 44 function cancelTravel() { 45 transition = null; 46 if (controls) controls.enabled = true; 47 $('pause-motion').hidden = !tourRunning; 48 } 49 50 function stopJourney() { 51 tourRunning = false; clearTimeout(tourTimer); cancelTravel(); 52 $('tour').textContent = 'Take me through all five'; $('tour').setAttribute('aria-pressed', 'false'); 53 } 54 55 function requestRender() { 56 if (!active || raf || document.hidden) return; 57 raf = requestAnimationFrame(() => { 58 raf = 0; 59 if (!active || !renderer) return; 60 if (transition) { 61 const progress = Math.min(1, (performance.now() - transition.start) / 1700); 62 const ease = progress * progress * (3 - 2 * progress); 63 camera.position.lerpVectors(transition.from, transition.to, ease); 64 controls.target.lerpVectors(transition.targetFrom, transition.targetTo, ease); 65 camera.lookAt(controls.target); 66 if (progress === 1) { cancelTravel(); controls.update(); } 67 } 68 const viewport = sceneViewport(); 69 if (mode === 'moon') renderer.setViewport(0, 0, viewport.width, viewport.height); 70 else renderer.setViewport(0, viewport.bottom, viewport.width, viewport.usableHeight); 71 renderer.render(scene, camera); 72 const { width, usableHeight: height } = sceneViewport(); 73 labels.forEach((label, index) => { 74 label.hidden = mode !== 'solar' || !bodies[index].visible || !['all', 'inner'].includes($('focus').value); 75 if (label.hidden) return; 76 const point = bodies[index].position.clone().project(camera); 77 label.hidden = point.z > 1 || point.z < -1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1; 78 if (label.hidden) return; 79 const markerX = (point.x * .5 + .5) * width; 80 const labelWidth = label.offsetWidth || 70, labelHeight = label.offsetHeight || 20; 81 const preferredX = markerX + labelWidth + 16 > width ? markerX - labelWidth - 8 : markerX + 8; 82 const x = Math.max(8, Math.min(width - labelWidth - 8, preferredX)); 83 const y = Math.max(8, Math.min(height - labelHeight - 8, (-point.y * .5 + .5) * height - 12)); 84 label.style.transform = `translate(${x}px,${y}px)`; 85 }); 86 if (transition) requestRender(); 87 }); 88 } 89 90 function stop() { 91 playing = false;
92 clearTimeout(timer); 93 playButton.setAttribute('aria-pressed', 'false'); 94 playButton.textContent = reducedMotion.matches ? 'Watch the Moon change (optional)' : 'Watch the Moon change'; 95 } 96 97 function play() { 98 if (!active) return; 99 stopJourney(); 100 playing = true; 101 let steps = 0; 102 playButton.textContent = 'Pause'; 103 playButton.setAttribute('aria-pressed', 'true'); 104 const tick = () => { 105 if (!playing || document.hidden || !active) return stop(); 106 if (day >= yearData.frames.length - 1) { stop(); status.textContent = 'Youâve reached the end of this year. Choose another date in Explore more.'; return; } 107 day += 1; 108 updateFrame(); 109 if (++steps >= 30 || day === yearData.frames.length - 1) return stop(); 110 timer = setTimeout(tick, 420); // Exact daily samples; no invented interpolated states. 111 }; 112 timer = setTimeout(tick, 420); 113 } 114 115 async function loadYear(year, token) { 116 if (yearData?.year === year) return; 117 fetchController?.abort(); 118 fetchController = new AbortController(); 119 status.textContent = `Loading JPL positions for ${year}â¦`; 120 const response = await fetch(`/astronomy/scene.json?year=${year}`, { signal: fetchController.signal, credentials: 'omit' }); 121 if (!response.ok) throw new Error('Position data is temporarily unavailable.'); 122 const data = await response.json(); 123 if (token !== generation) return; 124 if (data.schema_version !== 1 || !Array.isArray(data.frames) || data.frames.length < 365) { 125 throw new Error('The position data could not be read.'); 126 } 127 yearData = data; 128 } 129 130 function dispose() { 131 generation += 1; 132 active = false; 133 loading = false; 134 stop(); stopJourney(); 135 fetchController?.abort(); 136 cancelAnimationFrame(raf); 137 raf = 0; 138 resizeObserver?.disconnect(); 139 controls?.dispose(); 140 materials?.dispose(); 141 scene?.traverse((object) => { 142 object.geometry?.dispose(); 143 const materials = Array.isArray(object.material) ? object.material : [object.material]; 144 materials.forEach((material) => { material?.map?.dispose(); material?.dispose(); }); 145 }); 146 if (renderer) { 147 renderer.dispose(); 148 renderer.forceContextLoss(); 149 renderer.domElement.remove(); 150 } 151 materials = ruler = renderer = scene = camera = solarCamera = moonCamera = controls = solar = sunMarker = moonGroup = moonMesh = moonLight = null; 152 moonTextureRequested = false; 153 bodies = []; tracks = []; labels = []; yearData = null; 154 $('labels').replaceChildren(); 155 root.querySelectorAll('[data-scene-controls]').forEach((element) => { element.hidden = true; }); 156 $('readout').hidden = true; 157 $('data').hidden = true; 158 $('poster').hidden = false; 159 $('launch').hidden = false; 160 $('activate').disabled = false; 161 $('activate').textContent = 'Take a closer look â'; 162 } 163 164 function buildScene(createMaterials) { 165 scene = new THREE.Scene(); 166 solarCamera = new THREE.PerspectiveCamera(43, 1, .01, 250); 167 // A nearby perspective camera distorts the visible illuminated fraction. 168 // Orthographic projection represents the Moon as viewed from distant Earth. 169 moonCamera = new THREE.OrthographicCamera(-3, 3, 3, -3, .1, 250); 170 camera = solarCamera; 171 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' }); 172 renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); 173 renderer.outputColorSpace = THREE.SRGBColorSpace; 174 renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; 175 const materialToken = generation; 176 materials = createMaterials(THREE, requestRender, () => active && materialToken === generation); 177 stage.prepend(renderer.domElement); 178 renderer.domElement.setAttribute('aria-hidden', 'true'); 179 renderer.domElement.addEventListener('webglcontextlost', (event) => { 180 event.preventDefault(); 181 if (active) { dispose(); status.textContent = 'The 3D graphics context was interrupted. Activate to try again.'; } 182 }); 183 controls = new OrbitControls(camera, renderer.domElement); 184 controls.enableDamping = false; // Render on demand; no idle animation loop. 185 controls.enablePan = false;
186 controls.minDistance = 1.5; 187 controls.maxDistance = 110; 188 controls.addEventListener('change', requestRender); 189 renderer.domElement.addEventListener('pointerdown', () => { if (transition || tourRunning) stopJourney(); }); 190 solar = new THREE.Group(); 191 scene.add(solar); 192 solar.add(new THREE.AmbientLight(0x8b9bbe, .6)); 193 const sunlight = new THREE.PointLight(0xffecd6, 4, 0, 0); 194 solar.add(sunlight); 195 sunMarker = materials.sun(.65); solar.add(sunMarker); 196 yearData.body_ids.forEach((id, index) => { 197 const body = new THREE.Mesh(new THREE.SphereGeometry(sizes[index], 64, 40), 198 new THREE.MeshStandardMaterial({ color: colors[index], roughness: .82, metalness: .02 })); 199 if (id === 'saturn') { 200 body.userData.ring = materials.ring(sizes[index]); 201 body.add(body.userData.ring); body.rotation.z = .4665; 202 } 203 if (id === 'earth') body.add(materials.rim(0x3999ff, sizes[index] * 1.035, .8)); 204 if (id === 'venus') body.add(materials.rim(0xffd9a5, sizes[index] * 1.035, .3)); 205 if (id === 'neptune' || id === 'uranus') body.add(materials.rim(colors[index], sizes[index] * 1.03, .45)); 206 bodies.push(body); solar.add(body); 207 const label = document.createElement('span'); label.textContent = names[index]; 208 labels.push(label); $('labels').append(label); 209 }); 210 // Deterministic decorative background points; never labelled as real stars. 211 let seed = 71831; 212 const random = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; 213 const stars = []; 214 for (let i = 0; i < 1100; i += 1) { 215 const theta = random() * Math.PI * 2, y = random() * 2 - 1, radius = Math.sqrt(1 - y * y); 216 stars.push(90 * radius * Math.cos(theta), 90 * y, 90 * radius * Math.sin(theta)); 217 } 218 const starGeometry = new THREE.BufferGeometry(); 219 starGeometry.setAttribute('position', new THREE.Float32BufferAttribute(stars, 3)); 220 scene.add(new THREE.Points(starGeometry, new THREE.PointsMaterial({ color: 0xb1c7ec, size: .06, transparent: true, opacity: .8 }))); 221 moonGroup = new THREE.Group(); 222 moonMesh = new THREE.Mesh(new THREE.SphereGeometry(1.6, 64, 40), 223 new THREE.MeshStandardMaterial({ color: 0xd2d3d5, roughness: 1 })); 224 moonGroup.add(moonMesh); 225 moonGroup.add(new THREE.AmbientLight(0xb0c0db, .025)); 226 moonLight = new THREE.DirectionalLight(0xfff5e5, 3.1); 227 moonGroup.add(moonLight); 228 scene.add(moonGroup); 229 const resize = () => { 230 if (!renderer) return; 231 const { width, height, bottom, usableHeight } = sceneViewport(); 232 solarCamera.aspect = width / usableHeight; solarCamera.updateProjectionMatrix(); 233 const halfMoonHeight = width < 650 ? 3.1 : 2.8; 234 const moonCenter = width < 650 ? -1.15 : 0; 235 moonCamera.left = -halfMoonHeight * width / height; 236 moonCamera.right = halfMoonHeight * width / height; 237 moonCamera.top = halfMoonHeight + moonCenter; moonCamera.bottom = -halfMoonHeight + moonCenter; moonCamera.updateProjectionMatrix(); 238 renderer.setSize(width, height); 239 // Leave real space for the readout on phones. Viewport cropping changes 240 // presentation, never the Moon's observer direction or phase geometry. 241 renderer.setViewport(0, bottom, width, usableHeight); 242 if (active) resetView(); 243 requestRender(); 244 }; 245 resize(); 246 resizeObserver = new ResizeObserver(resize); 247 resizeObserver.observe(stage); 248 ruler = new THREE.Group(); solar.add(ruler); 249 [1, 5, 10, 20, 30].forEach((distance) => { 250 const points = Array.from({ length: 129 }, (_, i) => new THREE.Vector3(Math.cos(i / 128 * Math.PI * 2) * distance, 0, Math.sin(i / 128 * Math.PI * 2) * distance)); 251 ruler.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), new THREE.LineBasicMaterial({ color: 0x8497bc, transparent: true, opacity: .12 }))); 252 }); 253 rebuildTracks(); 254 } 255 256 function rebuildTracks() { 257 tracks.forEach((track) => { solar.remove(track); track.geometry.dispose(); track.material.dispose(); }); 258 tracks = yearData.body_ids.map((id, index) => { 259 const geometry = new THREE.BufferGeometry().setFromPoints(yearData.frames.map((frame) => projectedPosition(frame.positions[index]))); 260 const track = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: colors[index], transparent: true, op
260acity: .38 })); 261 solar.add(track); return track; 262 }); 263 } 264 265 function fitSystemDistance(focus) { 266 // Fit the actual sampled paths in both dimensions. A distance chosen on 267 // a wide desktop canvas otherwise clips outer planets on narrow phones. 268 const direction = new THREE.Vector3(.12, .7, .7).normalize(); 269 const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), direction).normalize(); 270 const up = new THREE.Vector3().crossVectors(direction, right).normalize(); 271 const tanVertical = Math.tan(THREE.MathUtils.degToRad(solarCamera.fov / 2)); 272 const { width, usableHeight } = sceneViewport(); 273 const tanHorizontal = tanVertical * Math.max(width / usableHeight, .1); 274 const markerScale = $('scale').value === 'true' ? .18 : 1; 275 let distance = 2; 276 yearData.frames.forEach((frame) => frame.positions.forEach((position, index) => { 277 if (focus === 'inner' && index >= 4) return; 278 const point = projectedPosition(position), radius = sizes[index] * markerScale; 279 const horizontal = (Math.abs(point.dot(right)) + radius) / tanHorizontal; 280 const vertical = (Math.abs(point.dot(up)) + radius) / tanVertical; 281 distance = Math.max(distance, point.dot(direction) + Math.max(horizontal, vertical) * 1.2 + radius); 282 })); 283 return distance; 284 } 285 286 function resetView(animate = false) { 287 if (!active) return; 288 cancelTravel(); 289 const from = camera.position.clone(), targetFrom = controls.target.clone(); 290 camera.zoom = 1; camera.updateProjectionMatrix(); 291 if (mode === 'moon') { 292 camera.position.copy(vector(currentFrame().moon.earth_to_moon).multiplyScalar(-7)); 293 controls.target.set(0, 0, 0); 294 } else { 295 const focus = $('focus').value, index = yearData.body_ids.indexOf(focus); 296 if (index >= 0 || focus === 'sun') { 297 const body = index >= 0 ? bodies[index] : sunMarker; 298 const radius = index >= 0 ? sizes[index] * body.scale.x : .65 * body.scale.x; 299 controls.target.copy(body.position); 300 const { width, usableHeight } = sceneViewport(); 301 const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(solarCamera.fov / 2)) * Math.min(1, width / usableHeight)); 302 const visualRadius = radius * (focus === 'saturn' ? 2.35 : focus === 'sun' ? 1.6 : 1.12); 303 const distance = visualRadius / Math.sin(halfFov) * 1.15; 304 const towardSun = body.position.clone().negate().normalize(); 305 if (!towardSun.lengthSq()) towardSun.set(0, 0, 1); 306 const tangent = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), towardSun).normalize(); 307 const direction = towardSun.multiplyScalar(index === 2 ? .2 : .55).addScaledVector(tangent, index === 2 ? .98 : .8).add(new THREE.Vector3(0, .3, 0)).normalize(); 308 if (index === 2) { 309 // Surface rotation is illustrative, not a geophysical prediction. 310 // Start over Africa/Europe at the terminator, rather than open ocean. 311 body.rotation.y = -Math.atan2(direction.z, direction.x) - THREE.MathUtils.degToRad(15); 312 } 313 camera.position.copy(body.position).addScaledVector(direction, distance); 314 } else { 315 controls.target.set(0, 0, 0); 316 const direction = new THREE.Vector3(.12, .7, .7).normalize(); 317 const distance = fitSystemDistance(focus); 318 controls.maxDistance = Math.max(110, distance * 1.5); 319 camera.position.copy(direction.multiplyScalar(distance)); 320 } 321 } 322 const to = camera.position.clone(), targetTo = controls.target.clone(); 323 if (animate === true && mode === 'solar' && !reducedMotion.matches && !navigator.connection?.saveData) { 324 transition = { from, to, targetFrom, targetTo, start: performance.now() }; 325 camera.position.copy(from); controls.target.copy(targetFrom); controls.enabled = false; 326 $('pause-motion').hidden = false; 327 } 328 controls.update(); requestRender(); 329 } 330 331 function updateFrame() { 332 if (!active || !yearData) return; 333 const frame = currentFrame(), focus = $('focus').value; 334 camera = mode === 'moon' ? moonCamera : solarCamera; 335 controls.object = camera; 336 solar.visible = mode === 'solar'; moonGroup.visible = mode === 'moon'; 337 controls.enableRotate = mode === 'solar'; 338 ruler.visible = mode === 'solar' && $('scale').value === 'true' && ['all', 'inner'].includes(focus); 339 $('ruler').hidden = !ruler.visible; 340 $('cloud-control').hidden = mode !== 'solar' || focus !== 'earth'; 341 const markerScale = $('scale').value === 'true' ? .18 : 1; 342 sunMarker.scale.setScalar(markerScale); 343 controls.minDistance = mode === 'moon' ? 3.5 : ($('scale').value === 'true' ? .1 : .8);
344 bodies.forEach((body, index) => { 345 body.scale.setScalar(markerScale); 346 body.position.copy(projectedPosition(frame.positions[index])); 347 body.visible = focus === 'all' || (focus === 'inner' && index < 4) || focus === yearData.body_ids[index]; 348 tracks[index].visible = ['all', 'inner'].includes(focus) && body.visible; 349 }); 350 dateInput.value = frame.date; 351 scrub.max = yearData.frames.length - 1; scrub.value = day; 352 scrub.setAttribute('aria-valuetext', `${frame.date}, noon UTC`); 353 $('day').textContent = `Day ${day + 1} / ${yearData.frames.length}`; 354 const selected = yearData.body_ids.indexOf(focus); 355 if (selected >= 0 && mode === 'solar') materials.enhance(bodies[selected], selected); 356 if (selected === 2 && mode === 'solar') materials.clouds(bodies[2], $('clouds').checked); 357 sunMarker.visible = mode === 'solar' && (selected < 0 || focus === 'sun'); 358 if (mode === 'moon') { 359 moonLight.position.copy(vector(frame.moon.moon_to_sun).multiplyScalar(10)); 360 // Keep the mapped near side toward Earth. Libration and the small lunar 361 // pole tilt are deliberately omitted and disclosed beneath the scene. 362 const towardEarth = vector(frame.moon.earth_to_moon).negate().normalize(); 363 const north = new THREE.Vector3(0, 1, 0).addScaledVector(towardEarth, -towardEarth.y).normalize(); 364 const east = new THREE.Vector3().crossVectors(towardEarth, north).normalize(); 365 moonMesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(towardEarth, north, east)); 366 if (!moonTextureRequested) { 367 moonTextureRequested = true; 368 const token = generation; 369 new THREE.TextureLoader().load('/images/astronomy/moon-lroc-2019-1k.jpg', (texture) => { 370 if (!active || token !== generation) return texture.dispose(); 371 texture.colorSpace = THREE.SRGBColorSpace; 372 moonMesh.material.map = texture; moonMesh.material.color.set(0xffffff); 373 moonMesh.material.needsUpdate = true; requestRender(); 374 }, undefined, () => { /* A source texture outage leaves accurate phase geometry intact. */ }); 375 } 376 resetView(); // Geocentric phase must always be viewed from Earth's direction. 377 const lit = frame.moon.illuminated_fraction * 100; 378 $('object').textContent = 'The Moon, seen from Earth'; 379 $('fact').textContent = lit < 1 ? 'Just a sliver of sunlight' : lit > 99 ? 'Almost the whole face is lit' : `${Math.round(lit)}% of its face is sunlit`; 380 $('detail').textContent = `${new Date(`${frame.date}T12:00:00Z`).toLocaleDateString(undefined, { month: 'long', day: 'numeric', timeZone: 'UTC' })} · A round Moon, with a changing view of its light.`; 381 } else if (focus === 'sun') { 382 $('object').textContent = 'Our star'; 383 $('fact').textContent = 'One Sun. Eight planets.'; 384 $('detail').textContent = 'An illustrated close-up of the light we live by.'; 385 } else if (selected >= 0) { 386 const offset = camera.position.clone().sub(controls.target); 387 controls.target.copy(bodies[selected].position); camera.position.copy(controls.target).add(offset); controls.update(); 388 $('object').textContent = names[selected]; 389 const lightMinutes = magnitude(frame.positions[selected]) * 149597870.7 / 299792.458 / 60; 390 $('fact').textContent = selected === 5 ? 'A world surrounded by ice and rock' : `Sunlight takes about ${Math.round(lightMinutes)} minutes to get here`; 391 $('detail').textContent = selected === 2 ? 'Day on one side. City lights on the other. Historical imagery.' : 'Drag to see another side. Surface and orientation are illustrated.'; 392 } else { 393 $('object').textContent = focus === 'inner' ? 'The rocky inner planets' : 'Our solar system'; 394 $('fact').textContent = 'So much space between worlds'; 395 $('detail').textContent = $('scale').value === 'true' ? 'Distances are in proportion. The planet dots are enlarged so you can find them.' : 'This view brings the planets closer together. Their dots are enlarged.'; 396 } 397 const selectedDistance = selected >= 0 ? ` · ${magnitude(frame.positions[selected]).toFixed(3)} AU from the Sun` : ''; 398 const moonPrecision = mode === 'moon' ? ` · ${(frame.moon.illuminated_fraction * 100).toFixed(1)}% illuminated · ${Math.round(frame.moon.distance_km).toLocaleStr
398ing()} km from Earth's center` : selectedDistance; 399 $('precision').textContent = `${frame.date} at 12:00 UTC${moonPrecision}. Exact daily JPL positions; geometric view, without light-time correction. Surfaces and rotation are illustrative.`; 400 $('phase-tools').hidden = mode !== 'moon'; 401 $('gesture').textContent = mode === 'moon' ? 'Watch the days change, or choose a date in Explore more.' : 'Drag to turn the view. Pinch to get closer.'; 402 syncChapterNavigation(); 403 $('table-caption').textContent = `Planet positions on ${frame.date} at 12:00 UTC`; 404 $('table').replaceChildren(...frame.positions.map((position, index) => { 405 const row = document.createElement('tr'); 406 [names[index], magnitude(position).toFixed(6), ...position.map((v) => v.toFixed(6))].forEach((text, column) => { 407 const cell = document.createElement(column ? 'td' : 'th'); 408 if (!column) cell.scope = 'row'; cell.textContent = text; row.append(cell); 409 }); return row; 410 })); 411 requestRender(); 412 } 413 414 async function activate() { 415 if (active || loading) return; 416 loading = true; 417 const token = ++generation; 418 $('activate').disabled = true; $('activate').textContent = 'Opening your viewâ¦'; 419 try { 420 const [three, orbit, materialModule] = await Promise.all([ 421 import('/vendor/three-0.180.0/three.module.min.js'), 422 import('/vendor/three-0.180.0/OrbitControls.js'), 423 import(root.dataset.sceneMaterials), 424 loadYear(Number(dateInput.value.slice(0, 4)), token) 425 ]); 426 if (token !== generation || document.hidden) return; 427 THREE = three; OrbitControls = orbit.OrbitControls; 428 buildScene(materialModule.createMaterials); active = true; loading = false; 429 day = Math.max(0, yearData.frames.findIndex((frame) => frame.date === dateInput.value)); 430 root.querySelectorAll('[data-scene-controls]').forEach((element) => { element.hidden = false; }); 431 $('readout').hidden = false; $('data').hidden = false; $('launch').hidden = true; $('poster').hidden = true; 432 syncMode(); updateFrame(); resetView(); 433 status.textContent = mode === 'moon' 434 ? 'Choose a stop above, or use Next to see what comes next. Left and right arrow keys change the day.' 435 : 'Choose a stop above, or use Next to see what comes next. Arrow keys turn the view; Home resets it.'; 436 requestAnimationFrame(() => { if (active && token === generation) revealStage(); }); 437 } catch (error) { 438 if (token !== generation) return; 439 dispose(); 440 status.textContent = error.name === 'AbortError' ? '3D loading paused.' : '3D is unavailable on this device or connection. The calendar and viewing guides below are still available. You can activate again to retry.'; 441 } 442 } 443 444 $('activate').addEventListener('click', activate); 445 $('close').addEventListener('click', () => { dispose(); status.textContent = 'Your view is closed. Take another look whenever you like.'; $('activate').focus(); }); 446 $('reset').addEventListener('click', () => { stopJourney(); resetView(true); }); 447 $('pause-motion').addEventListener('click', () => { stopJourney(); stop(); status.textContent = 'Journey paused. Drag to explore this view, or choose another chapter.'; }); 448 $('clouds').addEventListener('change', () => { materials.clouds(bodies[2], $('clouds').checked); }); 449 playButton.addEventListener('click', () => playing ? stop() : play()); 450 scrub.addEventListener('input', () => { stop(); day = Number(scrub.value); updateFrame(); }); 451 $('scale').addEventListener('change', () => { stopJourney(); rebuildTracks(); updateFrame(); resetView(true); }); 452 $('focus').addEventListener('change', () => { stopJourney(); stop(); updateFrame(); resetView(true); }); 453 function syncMode() { 454 root.querySelectorAll('[data-scene-mode]').forEach((item) => item.setAttribute('aria-pressed', String(item.dataset.sceneMode === mode))); 455 root.querySelectorAll('[data-solar-setting]').forEach((item) => { item.hidden = mode === 'moon'; }); 456 stage.setAttribute('aria-label', mode === 'moon' ? 'Moon phase from Earth. Left and right arrows change the day. Use the date control to explore a different phase.' : 'Planets in 3D. Drag or use arrow keys to turn the view, plus and minus to zoom, Home to reset.'); 457 } 458 root.querySelectorAll('[data-scene-mode]').forEach((button) => button.addEventListener('click', () => { 459 stop(); stopJourney(); mode = button.dataset.sceneMode; syncMode(); updateFrame(); resetView(true); 460 })); 461 const chapters = Array.from(root.querySelectorAll('[data-scene-chapter]')); 462 function chapterIndex() { 463 if (mode === 'moon') return 1; 464 return chapters.findIndex((button) => button.dataset.mode === mode && button.dataset.focus === $('focus').value); 465 } 466 function syncChapterNavigation() { 467 const index = chapterIndex(), button = chapters[index];
468 chapters.forEach((item) => item.setAttribute('aria-pressed', String(item === button))); 469 $('progress').textContent = index >= 0 ? `${index + 1} OF ${chapters.length}` : 'YOUR OWN DETOUR'; 470 $('navigate-title').textContent = button?.dataset.question || `Meet ${names[yearData.body_ids.indexOf($('focus').value)] || 'the inner planets'}`; 471 $('chapter-copy').textContent = button?.dataset.copy || 'Drag to look around this world. Choose another stop above whenever you like.'; 472 const next = chapters[(index + 1) % chapters.length]; 473 $('next').textContent = `Next: ${next.querySelector('strong').textContent} â`; 474 } 475 function chapter(button, touring = false) { 476 stop(); if (!touring) stopJourney(); 477 mode = button.dataset.mode; $('focus').value = button.dataset.focus; $('scale').value = button.dataset.scale || 'compressed'; 478 chapters.forEach((item) => item.setAttribute('aria-pressed', String(item === button))); 479 $('chapter-title').textContent = button.querySelector('strong').textContent; 480 $('chapter-copy').textContent = button.dataset.copy; 481 syncMode(); rebuildTracks(); updateFrame(); resetView(true); 482 } 483 function revealStage() { 484 stage.scrollIntoView({ behavior: reducedMotion.matches ? 'auto' : 'smooth', block: 'center' }); 485 stage.focus({ preventScroll: true }); 486 } 487 chapters.forEach((button) => button.addEventListener('click', () => { chapter(button); revealStage(); })); 488 $('back').addEventListener('click', () => { chapter(chapters[(chapterIndex() - 1 + chapters.length) % chapters.length]); revealStage(); }); 489 $('next').addEventListener('click', () => { chapter(chapters[(chapterIndex() + 1) % chapters.length]); revealStage(); }); 490 $('tour').addEventListener('click', () => { 491 if (tourRunning) return stopJourney(); 492 revealStage(); 493 tourRunning = true; $('tour').textContent = 'Pause journey'; $('tour').setAttribute('aria-pressed', 'true'); 494 let index = 0; 495 const next = () => { 496 if (!tourRunning || !active || document.hidden) return stopJourney(); 497 chapter(chapters[index], true); $('pause-motion').hidden = false; 498 index += 1; 499 if (index < chapters.length) tourTimer = setTimeout(next, 10000); 500 else tourTimer = setTimeout(stopJourney, 10000); 501 }; 502 next(); 503 }); 504 $('share').addEventListener('click', async () => { 505 const url = new URL('/astronomy/solar-system/', window.location.origin); 506 Object.entries({ date: currentFrame().date, mode, focus: $('focus').value, scale: $('scale').value }
506).forEach(([key, value]) => url.searchParams.set(key, value)); 507 try { await navigator.clipboard.writeText(url.href); status.textContent = 'Link copied. Share it to bring someone to this same view.'; } 508 catch { status.textContent = `Share this view: ${url.href}`; } 509 }); 510 dateInput.addEventListener('change', async () => { 511 stop(); stopJourney(); 512 const requested = dateInput.value, year = Number(requested.slice(0, 4)), token = generation; 513 if (!active) return; 514 if (!requested || !dateInput.validity.valid || year < 2024 || year > 2049) { 515 dateInput.value = currentFrame().date; 516 status.textContent = 'Choose a date from 2024 through 2049. The current day remains on screen.'; 517 return; 518 } 519 dateInput.disabled = true; scrub.disabled = true; playButton.disabled = true; 520 try { 521 await loadYear(year, token); 522 if (token !== generation) return; 523 day = Math.max(0, yearData.frames.findIndex((frame) => frame.date === requested)); 524 rebuildTracks(); updateFrame(); resetView(); status.textContent = `Your view now shows ${requested}.`; 525 } catch (error) { 526 if (token !== generation) return; 527 status.textContent = 'That year could not be loaded. The previous positions are still shown.'; 528 dateInput.value = currentFrame().date; 529 } finally { 530 dateInput.disabled = false; scrub.disabled = false; playButton.disabled = false; 531 } 532 }); 533 stage.addEventListener('keydown', (event) => { 534 if (!active || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', '_', 'Home', 'Escape'].includes(event.key)) return; 535 event.preventDefault(); 536 if (event.key === 'Escape') { stop(); stopJourney(); return; } 537 if (transition || tourRunning) stopJourney(); 538 if (event.key === 'Home') return resetView(); 539 if (mode === 'moon' && event.key.startsWith('Arrow')) { 540 stop(); day = (day + (event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 1) + yearData.frames.length) % yearData.frames.length; 541 return updateFrame(); 542 } 543 if (mode === 'moon' && ['+', '=', '-', '_'].includes(event.key)) { 544 camera.zoom = Math.max(.5, Math.min(2, camera.zoom * (event.key === '+' || event.key === '=' ? 1.15 : .85))); 545 camera.updateProjectionMatrix(); return requestRender(); 546 } 547 const offset = camera.position.clone().sub(controls.target); 548 const spherical = new THREE.Spherical().setFromVector3(offset); 549 if (event.key === 'ArrowLeft') spherical.theta -= .12; 550 if (event.key === 'ArrowRight') spherical.theta += .12; 551 if (event.key === 'ArrowUp') spherical.phi -= .12; 552 if (event.key === 'ArrowDown') spherical.phi += .12; 553 if (event.key === '+' || event.key === '=') spherical.radius = Math.max(controls.minDistance, spherical.radius * .85); 554 if (event.key === '-' || event.key === '_') spherical.radius = Math.min(controls.maxDistance, spherical.radius * 1.15); 555 spherical.makeSafe(); camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(spherical)); 556 controls.update(); requestRender(); 557 }); 558 document.addEventListener('visibilitychange', () => { 559 if (document.hidden && (active || loading)) { dispose(); status.textContent = '3D paused while this page was hidden. Activate to continue exploring.'; } 560 }); 561 window.addEventListener('pagehide', dispose); 562 reducedMotion.addEventListener('change', () => { if (reducedMotion.matches) { stop(); stopJourney(); } }); 563 if ('IntersectionObserver' in window) { 564 new IntersectionObserver((entries) => { if (!entries[0].isIntersecting) { stop(); stopJourney(); } }, { threshold: 0 }).observe(stage); 565 } 566})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.