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.