PageSourceSearch

https://mapoftheuniverse.net/code/code.js?v=20260524c1

js mapoftheuniverse.net collected 2026-10-03 20:17:53 UTC 48,983 bytes, 1,000 lines download raw bytes

1// Vanilla-JS rewrite of code.js. Zero jQuery / jQuery-UI dependencies.
2// Bootstrap 5 vanilla JS API is used for modals/collapses (data-bs-* attributes
3// drive most of it; programmatic toggles use bootstrap.Modal / bootstrap.Collapse).
4
5(function () {
6    'use strict';
7
8    // ---------- Module-level state (was top-level var in the original) ----------
9    let current_checked = 3;
10    let visible_overlay = '#axis_set_01';
11    let user_clicked = false;
12    let carousel_handle = null;
13    let carousel_start_handle = null;
14    const CAROUSEL_START_DELAY_MS = 3000; // wait after the full map appears before the layer sequence begins
15    let animating = false;
16    let overlay_show = 'none'; // shared between hover-in and hover-out on .select-button
17
18    function mod(n, m) {
19        return ((n % m) + m) % m;
20    }
21
22    // ---------- Tiny fade helpers (replace jQuery .fadeIn/.fadeOut) ----------
23    // Inject the CSS rule once so the helpers can use a class-based opacity transition.
24    const FADE_STYLE_ID = '__vanilla_fade_style__';
25    if (!document.getElementById(FADE_STYLE_ID)) {
26        const styleEl = document.createElement('style');
27        styleEl.id = FADE_STYLE_ID;
28        styleEl.textContent =
29            '.__vfade{opacity:0!important;
29transition:opacity var(--vfade-dur,200ms) linear;}' +
30            '.__vfade-in{opacity:1!important;transition:opacity var(--vfade-dur,200ms) linear;}';
31        document.head.appendChild(styleEl);
32    }
33
34    // Show an element regardless of whether CSS hides it via `display: none`
35    // (jQuery's .show() semantics). Without this fallback, `style.display = ''`
36    // just removes the inline override and the CSS rule keeps the element hidden.
37    function show(el) {
38        if (!el) return;
39        el.style.display = '';
40        if (getComputedStyle(el).display === 'none') {
41            el.style.display = 'block';
42        }
43    }
44
45    // One-shot opacity-transitionend listener. Guarantees `cb` fires exactly once,
46    // either from the real `transitionend` (filtered to the `opacity` property) or
47    // from a safety setTimeout fallback. Always removes the listener so it can't
48    // leak into a later fade on the same element — this was the source of the
49    // "second carousel cycle is too fast" regression: an interrupted fadeOut left
50    // its listener attached, then the next cycle's fadeIn transitionend triggered
51    // the stale listener and the element was hidden the moment it became visible.
52    function _onceOpacityEnd(el, cb, fallbackMs) {
53        let fired = false;
54        const fire = function () {
55            if (fired) return;
56            fired = true;
57            el.removeEventListener('transitionend', handler);
58            cb();
59        };
60        const handler = function (e) {
61            if (e.propertyName === 'opacity') fire();
62        };
63        el.addEventListener('transitionend', handler);
64        setTimeout(fire, fallbackMs);
65    }
66
67    function fadeIn(el, ms, cb) {
68        if (!el) return;
69        const dur = (typeof ms === 'number') ? ms : 200;
70        el.style.setProperty('--vfade-dur', dur + 'ms');
71        // Make sure the element is in the layout before transitioning,
72        // even if CSS sets display:none on it.
73        show(el);
74        // start from current opacity 0 if hidden
75        el.classList.remove('__vfade-in');
76        el.classList.add('__vfade');
77        // Force a reflow so the next class change triggers a transition.
78        void el.offsetWidth;
79        el.classList.remove('__vfade');
80        el.classList.add('__vfade-in');
81        if (typeof cb === 'function') {
82            _onceOpacityEnd(el, cb, dur + 50);
83        }
84    }
85
86    function fadeOut(el, ms, cb) {
87        if (!el) return;
88        const dur = (typeof ms === 'number') ? ms : 200;
89        el.style.setProperty('--vfade-dur', dur + 'ms');
90        el.classList.remove('__vfade-in');
91        el.classList.add('__vfade');
92        _onceOpacityEnd(el, function () {
93            // Only set display:none if the element is still being faded out.
94            // If a subsequent fadeIn started before this completed, the class
95            // is now `__vfade-in` and hiding the element here would cancel it.
96            if (el.classList.contains('__vfade')) {
97                el.style.display = 'none';
98            }
99            if (typeof cb === 'function') cb();
100        }, dur + 50);
101    }
102
103    function isHidden(el) {
104        if (!el) return true;
105        // Match jQuery's :hidden semantics loosely — display:none counts as hidden.
106        return el.offsetParent === null || getComputedStyle(el).display === 'none';
107    }
108
109    // Linear scroll from the current scrollY to `targetY` over `durationMs`.
110    // Equivalent of jQuery $('html, body').animate({scrollTop: y}, {duration, easing:'linear'}).
111    // Used (only) for the cover down-arrow so the long descent into the map
112    // feels deliberate — the visitor watches galaxies pass by, not a snap.
113    // A second click while a scroll is in flight cancels the previous animation.
114    let _linearScrollRAF = null;
115    let _scrollSeqTimeout = null;
116    function _cancelScrollSeq() {
117        if (_linearScrollRAF != null) {
118            cancelAnimationFrame(_linearScrollRAF);
119            _linearScrollRAF = null;
120        }
121        if (_scrollSeqTimeout != null) {
122            clearTimeout(_scrollSeqTimeout);
123            _scrollSeqTimeout = null;
124        }
125    }
126    // Built-in easings. Linear = constant speed. EaseOutCubic = starts fast,
127    // decelerates to a soft landing at the destination — used between cover
128    // sentences so each stop doesn't feel like an abrupt halt.
129    const _easings = {
130        linear: function (t) { return t; },
131        easeOutCubic: function (t) { return 1 - Math.pow(1 - t, 3); },
132        // Soft take-off AND soft landing: accelerates gently from rest,
133        // cruises, then decelerates gently into the stop.
134        easeInOutCubic: function (t) {
135            return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
136        },
137    };
138    function scrollToLinear(targetY, durationMs, cb, easing) {
139        _cancelScrollSeq();
140        const startY = window.scrollY;
141        const deltaY = targetY - startY;
142        if (Math.abs(deltaY) < 1 || durationMs <= 0) {
143            window.scrollTo(0, targetY);
144            if (typeof cb === 'function') cb();
145            return;
146        }
147        const ease = (typeof easing === 'function')
148            ? easing
149            : (_easings[easing] || _easings.linear);
150        const startTime = (typeof performance !== 'undefined' && performance.now)
151            ? performance.now()
152            : Date.now();
153        const step = function (now) {
154            const elapsed = (typeof now === 'number' ? now : Date.now()) - startTime;
155            const t = Math.min(elapsed / durationMs, 1);
156            const eased = ease(t);
157            window.scrollTo(0, Math.round(startY + deltaY * eased));
158            if (t < 1) {
159                _linearScrollRAF = requestAnimationFrame(step);
160            } else {
161                _linearScrollRAF = null;
162                if (typeof cb === 'function') cb();
163            }
164        };
165        _linearScrollRAF = requestAnimationFrame(step);
166    }
167    // One CONTINUOUS scroll from the current position to `targetY` that EASES
168    // ITS SPEED DOWN near each position in `slowPoints` (to `slowRatio` of the
169    // cruise speed) without ever stopping, then halts at targetY. Used by the
170    // mobile down-arrow: a single tap glides all the way to the map, slowing —
171    // but not pausing — as each cover sentence crosses the vertical center.
172    // The whole trip is normalized to take ~durationMs regardless of distance.
173    function scrollThroughSlowing(targetY, slowPoints, durationMs, slowRatio, slowWidthPx) {
174        _cancelScrollSeq();
175        const startY = window.scrollY;
176        const dist = targetY - startY;
177        if (dist < 1 || durationMs <= 0) {
178            window.scrollTo(0, targetY);
179            return;
180        }
181        const ratio = (typeof slowRatio === 'number') ? slowRatio : 0.25;
182        const width = (typeof slowWidthPx === 'number') ? slowWidthPx : window.innerHeight * 0.18;
183        const points = (slowPoints || []).filter(function (p) {
184            return p > startY + 5 && p < targetY - 5;
185        });
186        // Speed multiplier at a given scroll position: 1 in open space, dipping
187        // smoothly to `ratio` at a slow point.
188        const scaleAt = function (pos) {
189            if (!points.length || width <= 0) return 1;
190            let nearest = Infinity;
191            for (let i = 0; i < points.length; i++) {
192                const d = Math.abs(pos - points[i]);
193                if (d < nearest) nearest = d;
194            }
195            if (nearest >= width) return 1;
196            const t = nearest / width;          // 0 at the point → 1 at the edge
197            const s = t * t * (3 - 2 * t);       // smoothstep
198            return ratio + (1 - ratio) * s;
199        };
200        // Normalize: ∫ dy / scale(y) sets the cruise speed so the trip lasts
201        // durationMs even though the speed varies.
202        const STEPS = 200;
203        const stepPx = dist / STEPS;
204        let integral = 0;
205        for (let i = 0; i < STEPS; i++) {
206            integral += stepPx / scaleAt(startY + (i + 0.5) * stepPx);
207        }
208        const vMax = integral / durationMs;      // px per ms at full (cruise) speed
209        let y = startY;
210        let last = null;
211        const frame = function (now) {
212            const t = (typeof now === 'number') ? now : Date.now();
213            if (last == null) last = t;
214            const dt = Math.min(t - last, 50);   // cap dt so a tab-switch can't jump
215            last = t;
216            y += vMax * scaleAt(y) * dt;
217            if (y >= targetY) {
218                window.scrollTo(0, targetY);
219                _linearScrollRAF = null;
220                return;
221            }
222            window.scrollTo(0, Math.round(y));
223            _linearScrollRAF = requestAnimationFrame(frame);
224        };
225        _linearScrollRAF = requestAnimationFrame(frame);
226    }
227    // Walk through a list of {target, scrollMs, dwellMs} stops in sequence.
228    // Used by the cover down-arrow to pause at each .flavor-text sentence so
229    // the visitor has time to read it before the descent continues. Re-entering
230    // (e.g. user clicks the arrow again) cancels the previous sequence.
231    function scrollSequence(steps) {
232        _cancelScrollSeq();
233        let i = 0;
234        const runNext = function () {
235            if (i >= steps.length) return;
236            const step = steps[i++];
237            const afterScroll = function () {
238                if (step.dwellMs && step.dwellMs > 0) {
239                    _scrollSeqTimeout = setTimeout(function () {
240                        _scrollSeqTimeout = null;
241                        runNext();
242                    }, step.dwellMs);
243                } else {
244                    runNext();
245                }
246            };
247            scrollToLinear(step.target, step.scrollMs || 0, afterScroll, step.easing);
248        };
249        runNext();
250    }
251
252    // ---------- Convenience selector helpers ----------
253    const $$ = function (sel, root) { return Array.from((root || document).querySelectorAll(sel)); };
254    const $1 = function (sel, root) { return (root || document).querySelector(sel); };
255
256    function on(selector, event, handler, opts) {
257        $$(selector).forEach(function (el) {
258            el.addEventListener(event, handler, opts);
259        });
260    }
261
262    // ---------- Main initialisation (script has `defer`, DOM is ready) ----------
263    // Hide any .more-info up front.
264    $$('.more-info').forEach(function (el) { el.style.display = 'none'; });
265
266    // ---------- Dropdown items: show only the hovered .download-click-section ----------
267    on('.dropdown-item', 'mouseenter', function (e) {
268        $$('.download-click-section').forEach(function (el) { el.style.display = 'none'; });
269        const inner = $1('.download-click-section', this);
270        if (inner) show(inner);
271        e.stopPropagation();
272    });
273    on('.dropdown-item', 'mouseleave', function () {
274        $$('.download-click-section').forEach(function (el) { el.style.display = 'none'; });
275    });
276
277    // ---------- Phone banner box click → open modal with right picture ----------
278    on('.phone-banner-box', 'click', function () {
279        set_modal_pic(this.id);
280        const modalEl = document.getElementById('myModal');
281        if (modalEl && window.bootstrap && bootstrap.Modal) {
282            bootstrap.Modal.getOrCreateInstance(modalEl).toggle();
283        }
284    });
285
286    // ---------- Map-effect hover: data-driven ----------
287    // Map area/axis IDs to {slice, infoKey}. `slice` is the filename for #slice-map; null = no swap.
288    const SLICE_BASE = 'https://menard.pha.jhu.edu/MapoftheUniverse/Images/WebMap_V02/';
289    const MAP_EFFECT_TARGETS = {
290        area_01: { slice: 'near.png',     infoKey: 4 },
291        area_02: { slice: 'red.png',      infoKey: 3 },
292        area_03: { slice: 'quasars.png',  infoKey: 2 },
293        area_04: { slice: 'cmb.png',      infoKey: 1 },
294        area_05: { slice: 'galaxies.png', infoKey: 9 },
295        axis_01: { slice: null,           infoKey: 7 },
296        axis_02: { slice: null,           infoKey: 5 },
297        axis_03: { slice: null,           infoKey: 6 },
298        axis_04: { slice: null,           infoKey: 8 },
299    };
300
301    on('.map-effect', 'mouseenter', function (e) {
302        const cfg = MAP_EFFECT_TARGETS[e.target.id];
303        if (!cfg) return;
304        const container = $1('.map-container-info');
305        if (container && isHidden(container)) {
306            fadeIn(container, 200);
307        }
308        const sliceMap = document.getElementById('slice-map');
309        if (cfg.slice && sliceMap) {
310            sliceMap.src = SLICE_BASE + cfg.slice;
311        }
312        const info = information[cfg.infoKey];
313        if (!info || !container) return;
314        const h1 = $1('h1', container);
315        const img = $1('img', container);
316        const p = $1('p', container);
317        if (h1) h1.textContent = info.title;
318        if (img) img.src = info.img;
319        if (p) p.textContent = info.caption;
320    });
321
322    on('.map-effect', 'mouseleave', function () {
323        const container = $1('.map-container-info');
324        if (container) fadeOut(container, 100);
325        const sliceMap = document.getElementById('slice-map');
326        if (sliceMap) sliceMap.src = SLICE_BASE + 'total.png';
327    });
328
329    // ---------- Centered dropdown menu: don't close when clicked, hide downloads ----------
330    on('.dropdown-menu-center', 'click', function (e) {
331        e.stopPropagation();
332        $$('.download-click-section').forEach(function (el) { el.style.display = 'none'; });
333    });
334
335    // ---------- "Bottom arrow" smooth-scrolls to map ----------
336    // Constraint: with `.mapbox > img { max-height: 110vh }`, the rendered map
337    // is ~10% taller than the viewport on typical desktops, so the entire
338    // map can't fit at once. The labels themselves span only ~90% of the map
339    // (top one at top:8%, bottom one at bottom:2%), so on a viewport of
340    // height ~1100px BOTH labels CAN fit simultaneously — but only inside a
341    // narrow band of valid scroll positions.
342    //
343    // Compute the range of scrollY for which both labels are visible, then
344    // pick the midpoint of that range. `getBoundingClientRect` honors the
345    // `transform: translate(-50%, -50%)` on the labels and returns the actual
346    // visual edges, which is exactly what we need for visibility checks.
347    // Cover-scroll tunables, shared by the down-arrow auto-sequence AND the
348    // keyboard step-through.
349    const COVER_DWELL_MS = 4000;    // auto-sequence: dwell at each sentence
350    const COVER_SEGMENT_MS = 1200;  // scroll BETWEEN sentences (ease-in-out)
351    const COVER_TAIL_MS = 3000;     // final scroll to the map (ease-in-out)
352    const COVER_EASING = 'easeInOutCubic';
353
354    // The map landing scrollY. Mobile (< 992px): bottom of .scroll-to-map so the
355    // three action buttons are in view. Desktop: midpoint of the band where both
356    // axis labels are visible, biased per user fine-tuning. Falls back to .mapbox.
357    function computeMapTarget() {
358        const winW = window.innerWidth;
359        const winH = window.innerHeight;
360        const isMobile = winW < 992;
361        let scrollTarget = null;
362        if (isMobile) {
363            const stm = $1('.scroll-to-map');
364            if (stm) {
365                const rect = stm.getBoundingClientRect();
366                scrollTarget = rect.top + window.scrollY + stm.offsetHeight - winH;
367            }
368        } else {
369            const top = $1('.angle-on-sky-axis');
370            const bot = $1('.you-are-here-axis');
371            if (top && bot) {
372                const topRect = top.getBoundingClientRect();
373                const botRect = bot.getBoundingClientRect();
374                const topVisualPageY = topRect.top + window.scrollY;
375                const botVisualPageY = botRect.bottom + window.scrollY;
376                const lower = botVisualPageY - winH;
377                const upper = topVisualPageY;
378                const baseTarget = lower <= upper ? (lower + upper) / 2 : lower;
379                scrollTarget = baseTarget - 0.0625 * bot.offsetHeight;
380            }
381        }
382        if (scrollTarget == null) {
383            const mapbox = $1('.mapbox');
384            if (!mapbox) return null;
385            const rect = mapbox.getBoundingClientRect();
386            scrollTarget = rect.top + window.scrollY + mapbox.offsetHeight - winH;
387        }
388        return Math.max(0, scrollTarget);
389    }
390
391    // Ordered list of cover stops: each .flavor-text sentence centered
392    // (clamped ≥ 0) — sentence 1 at top:40% clamps to ~0 since it can't be
393    // centered by scrolling down — then the map landing. Each entry carries
394    // the glide duration to use when scrolling TO it.
395    function buildCoverStops() {
396        const winH = window.innerHeight;
397        const list = [];
398        $$('.cover .flavor-text').forEach(function (el) {
399            const rect = el.getBoundingClientRect();
400            const centerPageY = rect.top + window.scrollY + el.offsetHeight / 2;
401            list.push({ target: Math.max(0, centerPageY - winH / 2), ms: COVER_SEGMENT_MS });
402        });
403        const mapTarget = computeMapTarget();
404        if (mapTarget != null) list.push({ target: mapTarget, ms: COVER_TAIL_MS });
405        return list;
406    }
407
408    // Advance ONE stop below the current scroll position (manual pacing, no
409    // dwell). Returns true if it moved, false if already at/past the last stop.
410    // Shared by the keyboard step-through and the mobile down-arrow tap.
411    function advanceOneCoverStop() {
412        const stops = buildCoverStops();
413        if (!stops.length) return false;
414        const currentY = window.scrollY;
415        const next = stops.find(function (s) { return s.target > currentY + 5; });
416        if (!next) return false;
417        scrollToLinear(next.target, next.ms, null, COVER_EASING);
418        return true;
419    }
420
421    // Run the full automatic timed sequence — pause (dwell) at each sentence so
422    // the visitor can read it, then continue to the map.
423    function runCoverAutoSequence() {
424        const currentY = window.scrollY;
425        const all = buildCoverStops();
426        const stops = [];
427        all.forEach(function (s, i) {
428            // Only keep stops we haven't already scrolled past (so a repeat
429            // click from mid-descent doesn't bounce the user back up).
430            if (s.target >= currentY - 5) {
431                const isMap = (i === all.length - 1);
432                stops.push({
433                    target: s.target,
434                    scrollMs: s.ms,
435                    dwellMs: isMap ? 0 : COVER_DWELL_MS,
436                    easing: COVER_EASING,
437                });
438            }
439        });
440        // First stop is usually scrollY≈0 (sentence 1 already in view) — skip its
441        // zero-distance scroll but keep the dwell.
442        if (stops.length > 0 && Math.abs(stops[0].target - currentY) < 5) {
443            stops[0].scrollMs = 0;
444        }
445        if (stops.length) scrollSequence(stops);
446    }
447
448    // Cover stop scroll-positions (sentence centers), excluding the map landing.
449    function coverSentenceTargets() {
450        const stops = buildCoverStops();
451        return stops.slice(0, -1).map(function (s) { return s.target; });
452    }
453
454    // Down-arrow tap:
455    //  - Mobile (< 992px): ONE continuous slow scroll all the way to the map,
456    //    easing speed down (not stopping) as each sentence crosses center.
457    //  - Desktop: run the full automatic timed sequence (dwell at each sentence).
458    const MOBILE_TRIP_MS = 9000; // total duration of the mobile glide to the map
459    on('.bottom-arrow', 'click', function () {
460        if (window.innerWidth < 992) {
461            const mapTarget = computeMapTarget();
462            if (mapTarget == null) return;
463            scrollThroughSlowing(mapTarget, coverSentenceTargets(), MOBILE_TRIP_MS);
464        } else {
465            runCoverAutoSequence();
466        }
467    });
468
469    // Keyboard step-through: Enter / Space / ArrowDown advance ONE stop at a
470    // time (manual pacing, no dwell). From the landing, the first press centers
471    // sentence 2 — sentence 1 sits at top:40% and can't be centered by scrolling
472    // down, so it's the starting view — then sentence 3, then the map. Ignored
473    // while typing in a control or when a modal is open.
474    document.addEventListener('keydown', function (e) {
475        const k = e.key;
476        if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar' && k !== 'ArrowDown') return;
477        const t = e.target;
478        if (t && t.closest && t.closest('input, textarea, select, button, a, [contenteditable]')) return;
479        if (document.querySelector('.modal.show')) return;
480        if (advanceOneCoverStop()) {
481            e.preventDefault();
482        }
483        // else: already at/past the last stop — let the key do its default.
484    });
485
486    // ---------- Info-accordion: toggle column widths on open/close ----------
487    on('.info-accordion', 'click', function () {
488        const isCollapsed = this.classList.contains('collapsed');
489        const otherCols = $$('.other-col');
490        if (isCollapsed) {
491            otherCols.forEach(function (el) {
492                el.classList.remove('col-lg-3', 'this-col-change');
493                el.classList.add('col-lg-6');
494            });
495        } else {
496            otherCols.forEach(function (el) {
497                el.classList.remove('col-lg-6');
498                el.classList.add('col-lg-3', 'this-col-change');
499            });
500        }
501        $$('.more-info').forEach(function (el) { el.style.display = 'none'; });
502        $$('.read-more').forEach(function (el) { show(el); });
503        $$('.info-col').forEach(function (el) {
504            el.classList.remove('col-lg-6');
505            el.classList.add('col-lg-3');
506        });
507    });
508
509    // ---------- Description click: stop carousel, hide hover overlays ----------
510    // (The image-swap loop on data-src is now dead — native loading="lazy" replaced it.)
511    on('.description', 'click', function () {
512        $$('.hover-map-overlay').forEach(function (el) { fadeOut(el, 100); });
513        clearTimeout(carousel_start_handle);
514        if (carousel_handle) {
515            clearInterval(carousel_handle);
516            carousel_handle = null;
517        }
518    });
519
520    // ---------- Banner switch: toggle map-section <-> banner-section ----------
521    let toggle_banner = false;
522
523    // Land scroll position at the BOTTOM of `el`. The original jQuery flow was
524    //   $(window).scrollTop(el.offset().top + el.outerHeight() - $(window).height())
525    // fired synchronously inside the fadeOut() callback. The vanilla equivalent has
526    // had repeated trouble landing at the right spot — depending on which transition
527    // fires its callback first, scrollY can be auto-clamped, getBoundingClientRect
528    // can return values from a layout that hasn't fully flushed yet, or the browser
529    // can re-scroll behind us when the just-shown element gains focus or tab-stops.
530    //
531    // Robust approach: use Element.scrollIntoView({block:'end'}) — the spec-blessed
532    // call that asks the browser to align the element's bottom with the viewport's
533    // bottom. Then ALSO fire the legacy scrollTo(0, target) at multiple timings
534    // (sync, after rAF, after setTimeout) so a clamp/race that affects one path
535    // is covered by another.
536    function scrollBottomTo(el) {
537        if (!el) return;
538        const tryScroll = function () {
539            if (!el || !el.offsetParent && getComputedStyle(el).display === 'none') return;
540            // 1. scrollIntoView with block:'end' — easiest correct semantics.
541            try {
542                el.scrollIntoView({ block: 'end', inline: 'nearest', behavior: 'auto' });
543            } catch (_) {
544                el.scrollIntoView(false);
545            }
546            // 2. Belt-and-suspenders: also compute the absolute target ourselves and
547            //    issue a legacy two-arg scrollTo, which is always instant.
548            const rect = el.getBoundingClientRect();
549            const target = rect.top + window.scrollY + el.offsetHeight - window.innerHeight;
550            window.scrollTo(0, Math.max(0, target));
551        };
552        // Fire immediately, after the next two animation frames, then again at
553        // 100ms, 500ms and 1000ms. Mobile browsers (especially iOS Safari with
554        // URL-bar transitions) can take significantly longer to settle layout
555        // after a cover-hide + fadeOut + fadeIn chain than desktop browsers.
556        tryScroll();
557        requestAnimationFrame(function () {
558            tryScroll();
559            requestAnimationFrame(tryScroll);
560        });
561        setTimeout(tryScroll, 100);
562        setTimeout(tryScroll, 500);
563        setTimeout(tryScroll, 1000);
564    }
565
566    on('.banner-switch', 'click', function () {
567        const mapSection = $1('.map-section');
568        const bannerSection = $1('.banner-section');
569        const cover = $1('.cover');
570
571        if (!toggle_banner) {
572            if (mapSection) {
573                fadeOut(mapSection, 400, function () {
574                    if (bannerSection) {
575                        // Run the multi-attempt scroll AND request one final
576                        // scroll inside fadeIn's completion callback, when
577                        // layout is fully settled (especially needed on mobile
578                        // where viewport/URL-bar transitions race the fade).
579                        fadeIn(bannerSection, 800, function () {
580                            scrollBottomTo(bannerSection);
581                        });
582                        scrollBottomTo(bannerSection);
583                    }
584                });
585            }
586            if (cover) cover.style.display = 'none';
587            toggle_banner = true;
588        } else {
589            if (bannerSection) {
590                fadeOut(bannerSection, 400, function () {
591                    const onMapShown = function () {
592                        scrollBottomTo($1('.scroll-to-map'));
593                    };
594                    if (mapSection) {
595                        fadeIn(mapSection, 800, onMapShown);
596                    }
597                    if (cover) show(cover);
598                    onMapShown();
599                });
600            }
601            toggle_banner = false;
602        }
603    });
604
605    // ---------- Zoom-icon clicks: cycle through zoom-level radio buttons ----------
606    on('.zoom-icon', 'click', function (e) {
607        let change = 1;
608        if (e.target.classList.contains('plus-icon')) {
609            change = -1;
610        } else {
611            change = 1;
612        }
613        clearTimeout(carousel_start_handle);
614        clearInterval(carousel_handle);
615        const options = ['#full', '#outer', '#near', '#close', '#near_galaxy_view'];
616        const checkedEl1 = $1('input[name="options-outlined"]:checked');
617        const checkedEl2 = $1('input[name="options-outlined2"]:checked');
618        const checked = checkedEl1 ? checkedEl1.value : null;
619        const other_checked = checkedEl2 ? checkedEl2.value : null;
620        let true_checked = 0;
621        const order = ['3', '2', '1', '4', '5'];
622        if (checked != current_checked) {
623            const r = $1('input[type="radio"][name="options-outlined2"][value="' + checked + '"]');
624            if (r) r.checked = true;
625            true_checked = checked;
626        } else {
627            const r = $1('input[type="radio"][name="options-outlined"][value="' + other_checked + '"]');
628            if (r) r.checked = true;
629            true_checked = other_checked;
630        }
631        const targetSel = options[mod(order.indexOf(true_checked) + change, 5)];
632        const targetEl = $1(targetSel);
633        if (targetEl) targetEl.checked = true;
634        zoomlevel();
635    });
636
637    // ---------- Select-button hover: show the matching hover-map overlay ----------
638    on('.select-button', 'mouseenter', function () {
639        const id = this.id;
640        if (current_checked == 3 && id == 'near_label') {
641            overlay_show = '#near_from_full';
642        } else if (current_checked == 3 && id == 'outer_label') {
643            overlay_show = '#outer_from_full';
644        } else if (current_checked == 2 && id == 'near_label') {
645            overlay_show = '#near_from_outer';
646        } else if (current_checked == 2 && (id == 'close_label' || id == 'near_galaxy_view_label')) {
647            overlay_show = '#close_from_outer';
648        } else if (current_checked == 3 && (id == 'close_label' || id == 'near_galaxy_view_label')) {
649            overlay_show = '#close_from_full';
650        } else if (current_checked == 1 && (id == 'close_label' || id == 'near_galaxy_view_label')) {
651            overlay_show = '#close_from_near';
652        } else {
653            overlay_show = 'none';
654        }
655        if (overlay_show !== 'none') {
656            const el = $1(overlay_show);
657            if (el) show(el);
658        }
659    });
660    on('.select-button', 'mouseleave', function () {
661        if (overlay_show && overlay_show !== 'none') {
662            const el = $1(overlay_show);
663            if (el) el.style.display = 'none';
664        }
665    });
666
667    // ---------- Any input change → zoomlevel() ----------
668    $$('input').forEach(function (el) {
669        el.addEventListener('change', function () {
670            user_clicked = true;
671            zoomlevel();
672        });
673    });
674
675    // ---------- Zoom-button click clears the carousel timer ----------
676    on('.zoom_button', 'click', function () {
677        clearTimeout(carousel_start_handle);
678        clearInterval(carousel_handle);
679    });
680
681    // ---------- Banner-switch hover: fade #overlay ----------
682    on('.banner-switch-hover', 'mouseenter', function () {
683        const o = document.getElementById('overlay');
684        if (o) fadeIn(o, 100);
685    });
686    on('.banner-switch-hover', 'mouseleave', function () {
687        const o = document.getElementById('overlay');
688        if (o) fadeOut(o, 100);
689    });
690
691    // ---------- Term-hover inside banner-info-box: swap explanation/skyview imgs ----------
692    on('.banner-info-box >p> .term-hover', 'mouseenter', function () {
693        const parent = this.parentElement;
694        if (!parent) return;
695        const siblings = Array.from(parent.parentElement.children).filter(function (c) { return c !== parent; });
696        siblings.forEach(function (s) {
697            if (s.matches('img.explanation_image')) s.style.display = 'none';
698            if (s.matches('img.skyview_image')) show(s);
699        });
700    });
701    on('.banner-info-box >p> .term-hover', 'mouseleave', function () {
702        const parent = this.parentElement;
703        if (!parent) return;
704        const siblings = Array.from(parent.parentElement.children).filter(function (c) { return c !== parent; });
705        siblings.forEach(function (s) {
706            if (s.matches('img.explanation_image')) show(s);
707            if (s.matches('img.skyview_image')) s.style.display = 'none';
708        });
709    });
710
711    // ---------- Scroll-driven effects (rAF-throttled) ----------
712    const UNIVERSE_AGE_GYR = 13.7;
713    let scroll_ticking = false;
714    const mapbox = $1('.mapbox');
715    const bannerOutline = $1('.banner-outline');
716    const bannerNav = $1('.banner-navigator-section');
717    const barContainer = $1('.bar_container');
718    const lookbackA = document.getElementById('sidebar-lookback-time');
719    const lookbackB = document.getElementById('sidebar-lookback-time_2');
720    const scrollMsg = $1('.scroll-up-message');
721    const fullRadio = document.getElementById('full');
722
723    function onScroll() {
724        const winH = window.innerHeight;
725        const scrollY = window.scrollY;
726        const bottomOfScreen = scrollY + winH;
727
728        if (mapbox) {
729            const rect = mapbox.getBoundingClientRect();
730            const top_of_element = rect.top + scrollY;
731            const bottom_of_element = top_of_element + mapbox.offsetHeight;
732            if ((bottomOfScreen > top_of_element) && (scrollY < bottom_of_element)) {
733                if (!animating) {
734                    carousel_start_handle = setTimeout(carousel, CAROUSEL_START_DELAY_MS);
735                    animating = true;
736                }
737            } else {
738                if (fullRadio) fullRadio.checked = true;
739                zoomlevel();
740                clearTimeout(carousel_start_handle);
741                clearInterval(carousel_handle);
742                animating = false;
743            }
744        }
745
746        if (bannerOutline) {
747            const rect = bannerOutline.getBoundingClientRect();
748            const elementTop = rect.top + scrollY;
749            const bannerH = bannerOutline.offsetHeight;
750            const percentage = Math.max(Math.min((scrollY - elementTop) / bannerH * 100, 100), 0);
751            const bottomPercentage = Math.min((bottomOfScreen - elementTop) / bannerH * 100, 100);
752            if (bannerNav) {
753                bannerNav.style.top = percentage + '%';
754                bannerNav.style.height = (bottomPercentage - percentage) + '%';
755            }
756
757            if (barContainer) {
758                const barRect = barContainer.getBoundingClientRect();
759                const barTop = barRect.top + scrollY;
760                const barPercentage = (barTop - elementTop) / bannerH;
761                const lookback = UNIVERSE_AGE_GYR - barPercentage * UNIVERSE_AGE_GYR;
762                const lookbackStr = lookback.toFixed(1);
763                if (lookbackA) lookbackA.innerHTML = lookbackStr;
764                if (lookbackB) lookbackB.innerHTML = lookbackStr;
765            }
766        }
767
768        const docH = document.documentElement.scrollHeight;
769        const scrollPercent = docH > winH ? scrollY / (docH - winH) : 0;
770        if (scrollMsg) scrollMsg.style.opacity = String(1 - (1 - scrollPercent) * 6);
771
772        const center = scrollY + winH * 0.5;
773        const halfWin = winH * 0.5;
774        $$('.fade').forEach(function (el) {
775            const r = el.getBoundingClientRect();
776            const offset = r.top + scrollY + el.offsetHeight / 2;
777            const perc = Math.pow((center - offset) / halfWin, 2);
778            el.style.opacity = String(1 - perc);
779        });
780
781        scroll_ticking = false;
782    }
783
784    window.addEventListener('scroll', function () {
785        if (!scroll_ticking) {
786            window.requestAnimationFrame(onScroll);
787            scroll_ticking = true;
788        }
789    }, { passive: true });
790
791    // ---------- Banner-info-box click → open modal with the right picture ----------
792    on('.banner-info-box', 'click', function () {
793        const modalEl = document.getElementById('myModal');
794        if (modalEl && window.bootstrap && bootstrap.Modal) {
795            bootstrap.Modal.getOrCreateInstance(modalEl).toggle();
796        }
797        set_modal_pic(this.id);
798    });
799
800    // ---------- Reset modal contents when it hides ----------
801    const myModalEl = document.getElementById('myModal');
802    if (myModalEl) {
803        myModalEl.addEventListener('hidden.bs.modal', function () {
804            set_modal_pic(-1);
805        });
806    }
807
808    // ---------- Banner-navigator click: scroll to corresponding lookback time ----------
809    on('.banner-navigator, .edit', 'click', function (e) {
810        const parent = this.parentElement;
811        if (!parent) return;
812        const parentRect = parent.getBoundingClientRect();
813        const parentTopAbs = parentRect.top + window.scrollY;
814        const height = parent.offsetHeight;
815        if (!height) return;
816        const percentage = (e.pageY - parentTopAbs) / height;
817        const outline = $1('.banner-outline');
818        if (!outline) return;
819        const outRect = outline.getBoundingClientRect();
820        const outlineTopAbs = outRect.top + window.scrollY;
821        const pixelposition = outlineTopAbs + outline.offsetHeight * percentage - (window.innerHeight / 2);
822        window.scrollTo({ top: pixelposition, left: 0, behavior: 'smooth' });
823    });
824
825    // ---------- Outside-click: close collapses, reset column widths ----------
826    document.addEventListener('click', function (event) {
827        const target = event.target;
828        if (target instanceof Element &&
829            !target.closest('.info-box') &&
830            !target.closest('.accordion-button') &&
831            !target.closest('.zoom-container')) {
832            // Hide all currently-open Bootstrap collapses.
833            $$('.collapse.show').forEach(function (el) {
834                if (window.bootstrap && bootstrap.Collapse) {
835                    bootstrap.Collapse.getOrCreateInstance(el, { toggle: false }).hide();
836                } else {
837                    el.classList.remove('show');
838                }
839            });
840            $$('.more-info').forEach(function (el) { el.style.display = 'none'; });
841
842            $$('.other-col').forEach(function (el) {
843                el.classList.remove('col-lg-3');
844                el.classList.add('col-lg-6');
845            });
846            $$('.this-col').forEach(function (el) {
847                el.classList.remove('col-lg-6');
848                el.classList.add('col-lg-3');
849            });
850            $$('.other-col-2').forEach(function (el) {
851                el.classList.remove('col-lg-6');
852                el.classList.add('col-lg-8');
853            });
854            $$('.this-col-2').forEach(function (el) {
855                el.classList.remove('col-lg-6');
856                el.classList.add('col-lg-4');
857            });
858        }
859        $$('.download-click-section').forEach(function (el) { el.style.display = 'none'; });
860    });
861
862    // ---------- carousel(): cycle hover overlays + zoom levels ----------
863    function carousel() {
864        const options = ['#outer', '#near', '#close', '#near_galaxy_view', '#full'];
865        const hover_options = ['#outer_from_full', '#near_from_outer', '#close_from_near', '#Galaxy_View'];
866        let option_index = 0;
867
868        carousel_handle = setInterval(function () {
869            if (option_index % 2 || option_index == 6) {
870                if (option_index == 7) {
871                    option_index = 8;
872                }
873                const hov = $1(hover_options[Math.floor(option_index / 2)]);
874                if (hov) fadeOut(hov, 400); // jQuery default fadeOut() = 400ms
875
876                const opt = $1(options[Math.floor(option_index / 2)]);
877                if (opt) opt.checked = true;
878                zoomlevel();
879            } else {
880                const hov = $1(hover_options[Math.floor(option_index / 2)]);
881                if (hov) fadeIn(hov, 300);
882            }
883
884            option_index += 1;
885            option_index = option_index % 9;
886        }, 3000);
887    }
888
889    // ---------- set_modal_pic: data-driven, accepts banner-info-N or phone-banner-N ----------
890    // The selectors MUST be scoped to #myModal — otherwise querySelector returns
891    // the first match in the document, which is #creditModal's .modal-header > h1
892    // and .modal-footer > p (since #creditModal appears before #myModal in DOM
893    // order). The original jQuery code worked accidentally because $(sel).text()
894    // updates EVERY matching element, but querySelector returns only the first.
895    function set_modal_pic(id) {
896        const myModal = document.getElementById('myModal');
897        if (!myModal) return;
898        const modalImg = $1('.modal-body > img', myModal);
899        const modalH1  = $1('.modal-header > h1', myModal);
900        const modalP   = $1('.modal-footer > p', myModal);
901
902        let key = null;
903        if (typeof id === 'string') {
904            const m = id.match(/^(?:banner-info-|phone-banner-)(\d+)$/);
905            if (m) key = parseInt(m[1], 10);
906        }
907        const info = key !== null ? modal_info[key] : null;
908        if (info) {
909            if (modalImg) modalImg.src = info.img;
910            if (modalH1)  modalH1.textContent  = info.header;
911            if (modalP)   modalP.textContent   = info.caption;
912        } else {
913            if (modalImg) modalImg.src = '';
914            if (modalH1)  modalH1.textContent  = '';
915            if (modalP)   modalP.textContent   = '';
916        }
917    }
918
919    // ---------- zoomlevel(): swap the visible axis-set with a brief fade-through black ----------
920    // Matches the original jQuery flow exactly: fadeIn(blackOverlay), THEN inside the
921    // completion callback hide hover overlays + old axis_set, show new axis_set, fadeOut(blackOverlay).
922    // (jQuery's $.fadeIn("fast") = 200 ms, hence the 200 here.)
923    function zoomlevel() {
924        const checkedEl1 = $1('input[name="options-outlined"]:checked');
925        const checkedEl2 = $1('input[name="options-outlined2"]:checked');
926        const checked = checkedEl1 ? checkedEl1.value : null;
927        const other_checked = checkedEl2 ? checkedEl2.value : null;
928        let true_checked = 0;
929
930        if (checked != current_checked) {
931            const r = $1('input[type="radio"][name="options-outlined2"][value="' + checked + '"]');
932            if (r) r.checked = true;
933            true_checked = checked;
934        } else {
935            const r = $1('input[type="radio"][name="options-outlined"][value="' + other_checked + '"]');
936            if (r) r.checked = true;
937            true_checked = other_checked;
938        }
939
940        let axis_overlay;
941        if (true_checked == 1) {
942            axis_overlay = '#axis_set_03';
943        } else if (true_checked == 2) {
944            axis_overlay = '#axis_set_02';
945        } else if (true_checked == 4) {
946            axis_overlay = '#axis_set_04';
947        } else if (true_checked == 5) {
948            axis_overlay = '#axis_set_05';
949        } else {
950            axis_overlay = '#axis_set_01';
951        }
952
953        const blackOverlay = document.getElementById('black-overlay');
954        if (!blackOverlay) {
955            // Even without the black overlay, perform the swap.
956            $$('.hover-map-overlay').forEach(function (el) { el.style.display = 'none'; });
957            const oldVis = $1(visible_overlay);
958            if (oldVis) oldVis.style.display = 'none';
959            const newVis = $1(axis_overlay);
960            if (newVis) show(newVis);
961            visible_overlay = axis_overlay;
962            current_checked = true_checked;
963            return;
964        }
965
966        fadeIn(blackOverlay, 200, function () {
967            // Black overlay is fully opaque — now swap behind it.
968            $$('.hover-map-overlay').forEach(function (el) { el.style.display = 'none'; });
969            const oldVis = $1(visible_overlay);
970            if (oldVis) oldVis.style.display = 'none';
971            const newVis = $1(axis_overlay);
972            if (newVis) show(newVis);
973            visible_overlay = axis_overlay;
974            current_checked = true_checked;
975            fadeOut(blackOverlay, 200);
976        });
977    }
978
979    // ---------- Data tables (numeric keys + content fields preserved verbatim) ----------
980    var information = {
981        1: {title: "The Cosmic Microwave Background", caption: "This is an actual photograph of the first flash of light emitted soon after the Big Bang, 13.7 billion years ago. The expansion of the Universe has stretched this light, so it now reaches us as microwave radio waves. It marks the edge of the observable Universe.", img: "Images/Explanations/cmb_illust.png"},
982        2: {title: "Quasars", caption: "Quasars are massive black holes at the centers of certain galaxies. As they accrete surrounding gas, they become extremely bright and can be seen across the Universe. Their light is blueish. At these distances, galaxies have become too faint for the Sloan Digital Sky Survey telescope to detect.", img: "Images/Explanations/[email protected]" },
983        3: {title: "Luminous Red Galaxy", caption: "Luminous Red Galaxies are massive elliptical galaxies hundreds of times brighter than the Milky Way. Their old, cool stars give them a distinctly reddish color, and their brightness lets us see them billions of light years away.", img: "Images/Explanations/[email protected]"},
984        4: {title: "Near Galaxies", caption: "Each dot is a galaxy. Together they form a filamentary structure. Spiral galaxies are faint and blue. Elliptical galaxies are yellowish and much brighter, so we can see them farther away.", img: "Images/Explanations/Near_placeholder.png" },
985        5: {title: "Redshift", caption: "As the Universe expands, light traveling toward us gets stretched. This shifts its wavelength toward the red end of the spectrum — what astronomers call redshift. The more distant an object, the more its light is stretched, and the redder it appears.", img: "Images/Explanations/[email protected]"},
986        6: {title: "Lookback Time", caption: "Light takes time to travel. When we look at a distant object, we see it as it was when its light first set out. This delay is the lookback time. The farther we look, the further back in time we see.", img: "Images/Explanations/Lookback [email protected]" },
987        7: {title: "Angle on the Sky", caption: "The map shows a thin slice of the sky, about 10° wide. The full survey covers a larger region, but a 2D map could not display it all at once without saturating the image with dots.", img: "Images/Explanations/Lookback [email protected]" },
988        8: {title: "You are Here", caption: "We are currently in the Local Group, in the Milky Way galaxy, in the Orion Arm, in the Solar System, on Planet Earth.", img: "Images/Explanations/You are [email protected]" },
989        9: {title: "Galaxies", caption: "A galaxy is a vast system of stars, gas, dust, and dark matter held together by gravity. Each contains millions to trillions of stars. Most large galaxies host a supermassive black hole at their center.", img: "Images/Explanations/Galaxies_wikipedia cropped.png" },
990    };
991
992    var modal_info = {
993        1: {img: "https://menard.pha.jhu.edu/MapoftheUniverse/Images/Skyview/V_01/cmb.jpeg", header: "the cosmic microwave background", caption: "This is an actual photograph of the first flash of light emitted soon afterthe big bang, 13.7 billion years ago. This light has been stretched by the expansion of the Universe and arrives at us as radiowaves. This is the edge of the observable Universe."},
994        2: {img: "https://menard.pha.jhu.edu/MapoftheUniverse/Images/Skyview/V_01/12.jpeg", header: "redshifted quasars", caption: " At these distances, the expansion of the Universe is so great that the blue photons from quasars get stretched and appear redder. A bit farther, we encounter an epoch during which the Universe is filled with hydrogen gas that prevents the propagation of visible light we could observe today. This epoch is called the \"dark ages\"."},
995        3: {img: "https://menard.pha.jhu.edu/MapoftheUniverse/Images/Skyview/V_01/8.5.jpg", header: "quasars", caption: "Quasars are massive black holes located at the center of certain galaxies. As they accrete surrounding gas and stars, they become extremely bright and can be seen across the Universe. Their light is blueish."},
996        4: {img: "https://menard.pha.jhu.edu/MapoftheUniverse/Images/Skyview/V_01/4.5.jpeg", header: "redshifted elliptical galaxies", caption: "As the Universe expands, photons gets stretched and objects appear redder. This is the case for the elliptical galaxies. At these distances, they appear red to us.As we no longer detect the fainter spiral galaxies, the filamentary structure is less visible."},
997        5: {img: "https://menard.pha.jhu.edu/MapoftheUniverse/Images/Skyview/V_01/1.8.jpeg", header: "elliptical galaxies", caption: "Elliptical galaxies are yellowish and much brighter than spiral galaxies. We can see them farther away."},
998        6: {img: "https://menard.pha.jhu.edu/MapoftheUniverse/Images/Skyview/V_01/0.1.jpeg", header: "spiral galaxies", caption: "Each dot is a galaxy shown with its apparent color. Spiral galaxies are faint and blue. Our galaxy, the Milky Way, is a blue spiral that would look like one of these if we could observe it from the outside."},
999    };
1000})();

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.