1/** 2 * Homepage interactions. 3 * 4 * 1. Parallax: moves star layers at different rates on scroll, per scene 5 * (.tmf-space and .tmf-forts both have parallax backgrounds). 6 * 2. Hover-GIF swap: any [data-hover] image (with [data-static]) swaps to its 7 * animated GIF on hover â lander, astronaut, amp. 8 * 3. Forts carousel: cycles fort "planets" (prev / active / next) via arrows. 9 * 10 * All are progressive enhancement; motion features respect prefers-reduced- 11 * motion, and parallax is disabled on small screens. Vanilla JS, no deps. 12 */ 13( function () { 14 'use strict'; 15 16 var reduceMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; 17 var smallScreen = window.matchMedia( '(max-width: 700px)' ); 18 19 /* ---- Parallax (per scene) ----------------------------------------- */ 20 21 function initParallax() { 22 if ( reduceMotion ) { 23 return; 24 } 25 26 var scenes = Array.prototype.slice 27 .call( document.querySelectorAll( '.tmf-space, .tmf-forts' ) ) 28 .map( function ( el ) { 29 return { 30 root: el, 31 layers: Array.prototype.slice.call( el.querySelectorAll( '[data-parallax]' ) ), 32 }; 33 } ) 34 .filter( function ( s ) { 35 return s.layers.length; 36 } ); 37 38 if ( ! scenes.length ) { 39 return; 40 } 41 42 var ticking = false; 43 44 var apply = function () { 45 ticking = false; 46 var small = smallScreen.matches; 47 48 scenes.forEach( function ( scene ) { 49 var rect = scene.root.getBoundingClientRect(); 50 var offset = ( rect.top + rect.height / 2 ) - ( window.innerHeight / 2 ); 51 52 scene.layers.forEach( function ( l ) { 53 if ( small ) { 54 l.style.transform = ''; 55 return; 56 } 57 var speed = parseFloat( l.getAttribute( 'data-parallax' ) ) || 0; 58 l.style.transform = 'translate3d(0,' + ( offset * speed * -0.38 ).toFixed( 1 ) + 'px,0)'; 59 } ); 60 } ); 61 }; 62 63 var onScroll = function () { 64 if ( ! ticking ) { 65 ticking = true; 66 window.requestAnimationFrame( apply ); 67 } 68 }; 69 70 window.addEventListener( 'scroll', onScroll, { passive: true } ); 71 window.addEventListener( 'resize', onScroll ); 72 apply(); 73 } 74 75 /* ---- Generic hover-GIF swap --------------------------------------- */ 76 77 function initHoverGifs() { 78 if ( reduceMotion ) { 79 return; 80 } 81 82 var imgs = document.querySelectorAll( 'img[data-hover][data-static]' ); 83 84 Array.prototype.forEach.call( imgs, function ( img ) { 85 var staticSrc = img.getAttribute( 'data-static' ); 86 var hoverSrc = img.getAttribute( 'data-hover' ); 87 if ( ! staticSrc || ! hoverSrc ) { 88 return; 89 } 90 91 img.addEventListener( 'mouseenter', function () { 92 // Cache-bust so the GIF replays from the first frame each time. 93 img.src = hoverSrc + ( hoverSrc.indexOf( '?' ) === -1 ? '?' : '&' ) + 't=' + Date.now(); 94 } ); 95 img.addEventListener( 'mouseleave', function () { 96 img.src = staticSrc; 97 } ); 98 } ); 99 } 100 101 /* ---- Forts carousel ----------------------------------------------- */ 102 103 function initFortsCarousel() { 104 var stage = document.querySelector( '[data-forts-carousel]' ); 105 if ( ! stage ) { 106 return; 107 } 108 109 var items = Array.prototype.slice.call( stage.querySelectorAll( '.tmf-forts__item' ) ); 110 var prevBtn = stage.querySelector( '[data-forts-prev]' ); 111 var nextBtn = stage.querySelector( '[data-forts-next]' ); 112 var count = items.length; 113 114 if ( count < 2 ) { 115 return; // Nothing to cycle. 116 } 117 118 var current = 0; 119 var rels = new Array( count ); // each item's last position (for teleport detection) 120 121 var render = function () { 122 var half = count / 2; 123
124 items.forEach( function ( item, i ) { 125 // Signed distance from the centre, wrapped to the shortest way 126 // around the ring: 0 = centre, -1 = left, +1 = right, and 127 // anything further out is off-stage on that side. 128 var rel = i - current; 129 while ( rel > half ) { rel -= count; } 130 while ( rel < -half ) { rel += count; } 131 132 var zone; 133 if ( rel === 0 ) { 134 zone = 'is-active'; 135 } else if ( rel === -1 ) { 136 zone = 'is-prev'; 137 } else if ( rel === 1 ) { 138 zone = 'is-next'; 139 } else if ( rel < 0 ) { 140 zone = 'is-offstage-left'; 141 } else { 142 zone = 'is-offstage-right'; 143 } 144 145 // The one item that crosses the ring seam jumps more than one 146 // position â reposition it instantly (off-stage, so invisible) 147 // rather than letting it slide across the stage. 148 var teleport = ( typeof rels[ i ] === 'number' ) && Math.abs( rel - rels[ i ] ) > 1; 149 if ( teleport ) { 150 item.style.transition = 'none'; 151 } 152 153 item.classList.remove( 'is-active', 'is-prev', 'is-next', 'is-offstage-left', 'is-offstage-right' ); 154 item.classList.add( zone ); 155 156 if ( teleport ) { 157 void item.offsetWidth; // commit the jump before restoring transitions 158 item.style.transition = ''; 159 } 160 161 rels[ i ] = rel; 162 163 // Only the active planet is interactive / in the tab order. 164 var link = item.querySelector( '.tmf-forts__planet' ); 165 if ( link ) { 166 if ( 0 === rel ) { 167 link.removeAttribute( 'tabindex' ); 168 link.removeAttribute( 'aria-hidden' ); 169 } else { 170 link.setAttribute( 'tabindex', '-1' ); 171 link.setAttribute( 'aria-hidden', 'true' ); 172 } 173 } 174 } ); 175 }; 176 177 var go = function ( dir ) { 178 current = ( current + dir + count ) % count; 179 render(); 180 }; 181 182 if ( prevBtn ) { 183 prevBtn.addEventListener( 'click', function () { 184 go( -1 ); 185 } ); 186 } 187 if ( nextBtn ) { 188 nextBtn.addEventListener( 'click', function () { 189 go( 1 ); 190 } ); 191 } 192 193 // Left/right arrow keys when the carousel has focus. 194 stage.addEventListener( 'keydown', function ( e ) { 195 if ( e.key === 'ArrowLeft' ) { 196 go( -1 ); 197 } else if ( e.key === 'ArrowRight' ) { 198 go( 1 ); 199 } 200 } ); 201 202 render(); 203 } 204 205 /* ---- Photo carousel (center-emphasis, auto-scroll left) ----------- */ 206 207 function initPhotoCarousel() { 208 var track = document.querySelector( '[data-photo-carousel]' ); 209 if ( ! track ) { 210 return; 211 } 212 213 var originals = Array.prototype.slice.call( track.children ); 214 var n = originals.length; 215 if ( n < 1 ) { 216 return; 217 } 218 219 // For 2+ images, clone a full set before and after so the active slide 220 // always has neighbours to bleed past the edges and the loop is seamless. 221 var looping = n > 1; 222 if ( looping ) { 223 // Keep a reference to the first real slide so the prepended clones 224 // are inserted *in order* before it (inserting before firstChild in 225 // a loop would reverse them and duplicate a neighbour). 226 var firstReal = track.firstChild; 227 originals.forEach( function ( li ) { 228 var c = li.cloneNode( true ); 229 c.setAttribute( 'aria-hidden', 'true' ); 230 track.insertBefore( c, firstReal ); 231 } );
232 originals.forEach( function ( li ) { 233 var c = li.cloneNode( true ); 234 c.setAttribute( 'aria-hidden', 'true' ); 235 track.appendChild( c ); 236 } ); 237 } 238 239 var slides = Array.prototype.slice.call( track.children ); 240 var index = looping ? n : 0; // first "real" slide 241 var sideW = 0; 242 var centerW = 0; 243 244 // Read the final (post-transition) side/center widths with transitions 245 // disabled, so centering math never sees an in-progress width. 246 function measure() { 247 track.classList.add( 'is-measuring' ); 248 slides.forEach( function ( s ) { 249 s.classList.remove( 'is-center' ); 250 } ); 251 var probe = slides[ index ]; 252 sideW = probe.offsetWidth; 253 probe.classList.add( 'is-center' ); 254 centerW = probe.offsetWidth; 255 track.classList.remove( 'is-measuring' ); 256 // measure() leaves the active slide centered (no animation, since 257 // transitions were disabled during the pass). 258 } 259 260 // Center the active slide. Positions are deterministic â every slide 261 // before the active one is at side width â so we don't read layout 262 // mid-transition. 263 function center( animate ) { 264 // Guard against an out-of-range index from any caller so we never 265 // dereference an undefined slide. 266 if ( ! slides[ index ] ) { 267 index = ( ( index % slides.length ) + slides.length ) % slides.length; 268 } 269 270 // For an instant reposition, freeze transitions on the track AND the 271 // slides *before* changing anything, so neither the transform nor the 272 // width animates; a forced reflow commits the new values, then 273 // transitions are restored. 274 if ( ! animate ) { 275 track.classList.add( 'is-snapping' ); 276 } 277 278 slides.forEach( function ( s ) { 279 s.classList.remove( 'is-center' ); 280 } ); 281 slides[ index ].classList.add( 'is-center' ); 282 283 var x = ( window.innerWidth / 2 ) - ( index * sideW + centerW / 2 ); 284 track.style.transform = 'translateX(' + x + 'px)'; 285 286 if ( ! animate ) { 287 void track.offsetWidth; // flush the jump 288 track.classList.remove( 'is-snapping' ); 289 } 290 } 291 292 measure(); 293 center( false ); 294 track.classList.add( 'is-ready' ); 295 296 if ( ! looping || reduceMotion ) { 297 return; // Single image, or motion is off: leave it centered/static. 298 } 299 300 // When we've advanced one full set past the originals, jump back by a 301 // set with no animation â the slides repeat, so it's invisible. 302 track.addEventListener( 'transitionend', function ( e ) { 303 if ( e.propertyName === 'transform' && index >= 2 * n ) { 304 index -= n; 305 center( false ); 306 } 307 } ); 308 309 var timer = null; 310 function start() { 311 stop(); 312 timer = window.setInterval( function () { 313 // Safety net: the transitionend handler normally snaps index 314 // back by a set once it reaches the trailing clones. That event 315 // can be missed (e.g. a backgrounded tab suppresses transition 316 // events while this interval keeps firing), which would let 317 // index climb until slides[index] is undefined. Re-normalise 318 // here â the clone sets are identical, so an untransitioned 319 // snap-back is invisible â so index can never leave range. 320 if ( index >= 2 * n ) { 321 index -= n; 322 center( false ); 323 } 324 index++; 325 center( true ); 326 }, 2500 ); 327 } 328 function stop() { 329 if ( timer ) { 330 window.clearInterval( timer ); 331 timer = null; 332 } 333 } 334 335 start();
336 // Deliberately NO pause-on-hover: on touch devices `mouseenter` fires on 337 // tap but `mouseleave` frequently doesn't, which left the carousel stuck. 338 // It now scrolls continuously no matter what. 339 340 // Widths are vw-based, so re-measure and re-center on resize. 341 var rt = null; 342 window.addEventListener( 'resize', function () { 343 window.clearTimeout( rt ); 344 rt = window.setTimeout( function () { 345 measure(); 346 center( false ); 347 }, 150 ); 348 } ); 349 } 350 351 /* ---- Hero background video ---------------------------------------- */ 352 353 function initHeroVideo() { 354 var video = document.querySelector( '.tmf-hero__video' ); 355 if ( ! video ) { 356 return; 357 } 358 359 // Reduced motion: don't play a moving background. The hero image (poster 360 // + section background) stays as the still hero, and we drop the source 361 // so nothing downloads. 362 if ( reduceMotion ) { 363 video.removeAttribute( 'autoplay' ); 364 while ( video.firstChild ) { 365 video.removeChild( video.firstChild ); 366 } 367 video.load(); 368 video.remove(); 369 return; 370 } 371 372 // Fade the video in only once it's actually rendering frames, so the 373 // hero image carries the load and there's no stillâmotion "pop". CSS 374 // starts it at opacity 0 (.tmf-hero--has-video .tmf-hero__video). 375 var reveal = function () { 376 video.classList.add( 'is-playing' ); 377 }; 378 if ( ! video.paused && video.readyState >= 3 ) { 379 reveal(); // already playing (e.g. cached) 380 } else { 381 video.addEventListener( 'playing', reveal, { once: true } ); 382 } 383 384 // Some browsers won't honour the autoplay attribute until asked; nudge 385 // it, and ignore the promise rejection if the policy still blocks it 386 // (the poster/image simply remains). 387 var attempt = video.play(); 388 if ( attempt && typeof attempt.catch === 'function' ) { 389 attempt.catch( function () {} ); 390 } 391 } 392 393 initParallax(); 394 initHoverGifs(); 395 initFortsCarousel(); 396 initPhotoCarousel(); 397 initHeroVideo(); 398}() );
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.