PageSourceSearch

https://forecastbar.com/js/astronomy-scene.js?v=325e47f2502d

js forecastbar.com collected 2026-10-02 15:33:31 UTC 33,156 bytes, 566 lines download raw bytes

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.