1// WMM page interactions: smooth scroll, scroll-spy nav, galleries, lazy video. 2 3(() => { 4 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) 5 || window.innerWidth < 768; 6 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 7 window.isMobileInit = isMobile; 8 window.prefersReducedMotion = prefersReducedMotion; 9})(); 10 11// Smooth scroll + immediate active-nav on click 12document.addEventListener('DOMContentLoaded', () => { 13 document.querySelectorAll('a[href^="#"]').forEach((anchor) => { 14 anchor.addEventListener('click', (e) => { 15 const href = anchor.getAttribute('href'); 16 if (!href || href === '#') return; 17 const target = document.querySelector(href); 18 if (!target) return; 19 e.preventDefault(); 20 target.scrollIntoView({ behavior: 'smooth', block: 'start' }); 21 setActiveNav(href); 22 }); 23 }); 24}); 25 26function setActiveNav(hash) { 27 document.querySelectorAll('.nav-item, .nav-subitem').forEach((el) => { 28 el.classList.remove('active', 'parent-active'); 29 }); 30 const active = document.querySelector(`.nav-item[href="${hash}"], .nav-subitem[href="${hash}"]`); 31 if (!active) return; 32 active.classList.add('active'); 33 // sub-app link also lights its parent Applications item 34 if (active.classList.contains('nav-subitem')) { 35 document.querySelector('.nav-item[href="#applications"]')?.classList.add('parent-active'); 36 } 37} 38 39// Scroll-spy: last section top past the trigger line; works for top bar + left rail 40(() => { 41 const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches; 42 // targets in document order (order == vertical position) 43 const sections = () => Array.from( 44 document.querySelectorAll('main > section[id], #applications article[id]') 45 ); 46 const sidebar = document.querySelector('.sidebar'); 47 // targets without a nav hash fall back to the nearest preceding navigable section (bar never blanks) 48 const navHrefs = new Set( 49 [...document.querySelectorAll('.nav-item[href^="#"], .nav-subitem[href^="#"]')] 50 .map((a) => a.getAttribute('href')) 51 ); 52 53 const triggerY = () => { 54 // trigger at 170px: anchor landing = scroll-padding 80 + scroll-margin 80, +10 slack 55 if (isDesktop()) return 170; 56 const sb = document.querySelector('.sidebar'); 57 return (sb?.getBoundingClientRect().height ?? 0) + 24; // clear the mobile top bar 58 }; 59 60 // pose-frame thumb: spin triad with scroll 61 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 62 const SPIN_TURNS = 4; // full-page scroll = this many turns 63 64 const renderThumb = (pct) => { 65 if (!sidebar) return; 66 sidebar.style.setProperty('--scroll-progress', `${(pct * 100).toFixed(2)}%`); 67 if (!reduceMotion) { 68 sidebar.style.setProperty('--rail-spin', `${(pct * SPIN_TURNS * 360).toFixed(1)}deg`); 69 } 70 }; 71 72 let raf = null; 73 const update = () => { 74 raf = null; 75 76 // CSS reads --scroll-progress on .sidebar 77 if (sidebar) { 78 const max = document.documentElement.scrollHeight - window.innerHeight; 79 const pct = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0; 80 renderThumb(pct); 81 } 82 83 const line = triggerY(); 84 const list = sections(); 85 let current = ''; 86 for (const s of list) { 87 if (s.getBoundingClientRect().top - line > 0) break; 88 if (navHrefs.has(`#${s.id}`)) current = `#${s.id}`; 89 } 90 // before first target passes, light the first 91 if (!current) { 92 const first = list.find((s) => navHrefs.has(`#${s.id}`)); 93 if (first) current = `#${first.id}`; 94 } 95 // near bottom, force the LAST 96 if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4) { 97 for (let i = list.length - 1; i >= 0; i--) { 98 if (navHrefs.has(`#${list[i].id}`)) { current = `#${list[i].id}`; break; } 99 } 100 } 101 if (current) setActiveNav(current); 102 }; 103 104 const onScroll = () => { 105 if (raf === null) raf = requestAnimationFrame(update); 106 }; 107 108 window.addEventListener('scroll', onScroll, { passive: true }); 109 window.addEventListener('resize', onScroll, { passive: true }); 110 document.addEventListener('DOMContentLoaded', update); 111 // first pass in case DOMContentLoaded already fired
112 if (document.readyState !== 'loading') update(); 113 114 // Drag scrubber: thumb scrubs page scroll continuously (desktop: clientY/viewport; 115 // mobile: clientX/track). One scroll per frame; forced INSTANT (page smooth-scroll would float). 116 const makeScrubber = (thumb, fracFromEvent) => { 117 if (!thumb) return; 118 const docEl = document.documentElement; 119 let dragging = false, pending = null, dragRaf = null; 120 const apply = () => { 121 dragRaf = null; 122 if (pending === null) return; 123 const max = docEl.scrollHeight - window.innerHeight; 124 const pct = pending; pending = null; 125 window.scrollTo(0, pct * max); // instant (pairs with scrollBehavior='auto' set below) 126 renderThumb(pct); // glue thumb to pointer (progress + spin) 127 }; 128 const queue = (frac) => { 129 pending = Math.min(1, Math.max(0, frac)); 130 if (dragRaf === null) dragRaf = requestAnimationFrame(apply); 131 }; 132 thumb.addEventListener('pointerdown', (e) => { 133 dragging = true; 134 thumb.classList.add('dragging'); 135 docEl.style.scrollBehavior = 'auto'; 136 try { thumb.setPointerCapture(e.pointerId); } catch (_) {} 137 queue(fracFromEvent(e)); 138 e.preventDefault(); 139 }); 140 thumb.addEventListener('pointermove', (e) => { 141 if (dragging) { queue(fracFromEvent(e)); e.preventDefault(); } 142 }); 143 const endDrag = (e) => { 144 if (!dragging) return; 145 dragging = false; 146 thumb.classList.remove('dragging'); 147 docEl.style.scrollBehavior = ''; // restore the page's smooth-scroll 148 if (dragRaf !== null) { cancelAnimationFrame(dragRaf); dragRaf = null; } 149 pending = null; 150 try { thumb.releasePointerCapture(e.pointerId); } catch (_) {} 151 }; 152 thumb.addEventListener('pointerup', endDrag); 153 thumb.addEventListener('pointercancel', endDrag); 154 thumb.addEventListener('lostpointercapture', endDrag); 155 }; 156 157 // desktop: pointer Y / viewport height 158 makeScrubber(document.querySelector('.rail-thumb'), 159 (e) => e.clientY / (window.innerHeight || 1)); 160 // mobile: pointer X / track width 161 const railH = document.querySelector('.rail-h'); 162 makeScrubber(document.querySelector('.rail-h__thumb'), (e) => { 163 const r = railH.getBoundingClientRect(); 164 return (e.clientX - r.left) / (r.width || 1); 165 }); 166})(); 167 168// Galleries: arrow buttons (data-gallery="<id>"); data-sync-group members move to the same scene index together 169function gallerySlotStep(gallery) { 170 const slot = gallery.querySelector('.video-slot'); 171 if (!slot) return gallery.clientWidth * 0.8; 172 const gap = parseFloat(getComputedStyle(gallery).columnGap) || 12; // resolved --space-3, not a magic 12 173 return slot.getBoundingClientRect().width + gap; 174} 175 176// run cb when smooth scroll reaches target, or after a frame cap (missed animation can't wedge the carousel) 177function pollScrollSettled(g, target, cb) { 178 let frames = 0; 179 const tick = () => { 180 if (Math.abs(g.scrollLeft - target) <= 4 || ++frames > 90) cb(); 181 else requestAnimationFrame(tick); 182 }; 183 requestAnimationFrame(tick); 184} 185 186// Boundary wrap: move ONE slot across the strip so the wrapped scene slides in from the 187// leading edge, then restore DOM order and land scrollLeft at its true spot. Moving the node 188// (not cloning) keeps <video> state; compensate MUST be 'instant' â 'auto' resolves to CSS 189// smooth and gets coalesced away (Chrome). 190function wrapSlide(g, sign, onDone) { 191 const step = gallerySlotStep(g); 192 if (sign > 0) { 193 g.appendChild(g.firstElementChild); // first slot -> end 194 const max = g.scrollWidth - g.clientWidth; 195 g.scrollTo({ left: Math.max(0, max - step), behavior: 'instant' }); // hold the viewed slot in place 196 g.scrollTo({ left: max, behavior: 'smooth' }); // slide the wrapped slot in from the right 197 pollScrollSettled(g, max, () => { 198 try { 199 g.insertBefore(g.lastElementChild, g.firstElementChild); // restore original order 200 g.scrollTo({ left: 0, behavior: 'instant' }); // land at the first slot (scroll back to the LEFT) 201 } finally { onDone(); } // always release the lane, even if the restore throws 202 }); 203 } else { 204 g.insertBefore(g.lastElementChild, g.firstElementChild); // last slot -> front 205 g.scrollTo({ left: step, behavior: 'instant' }); // hold the viewed slot in place 206 g.scrollTo({ left: 0, behavior: 'smooth' }); // slide the wrapped slot in from the left 207 pollScrollSettled(g, 0, () => {
208 try { 209 g.appendChild(g.firstElementChild); // restore original order 210 g.scrollTo({ left: g.scrollWidth - g.clientWidth, behavior: 'instant' }); // land at the last slot (the RIGHT) 211 } finally { onDone(); } 212 }); 213 } 214} 215document.addEventListener('DOMContentLoaded', () => { 216 // per-lane tracked index (sync-group name or element): rapid clicks must not read mid-flight scrollLeft 217 const trackedIndex = new Map(); // lane key -> current slot index 218 const settleTimers = new Map(); // gallery element -> pending drag-reconcile timer 219 const wrapping = new Set(); // lane keys mid boundary-wrap (blocks re-entry + reconcile) 220 const laneKey = (g) => g.dataset.syncGroup || g; // shared group name, or the element itself 221 const cancelSettle = (g) => { 222 const t = settleTimers.get(g); 223 if (t !== undefined) { clearTimeout(t); settleTimers.delete(g); } 224 }; 225 226 document.querySelectorAll('.gallery-nav').forEach((btn) => { 227 btn.addEventListener('click', () => { 228 const galleryId = btn.dataset.gallery; 229 const gallery = galleryId ? document.getElementById(galleryId) : btn.closest('.video-gallery-section')?.querySelector('.video-gallery'); 230 if (!gallery) return; 231 const dir = btn.classList.contains('left') ? -1 : 1; 232 const behavior = window.prefersReducedMotion ? 'instant' : 'smooth'; // explicit instant, not CSS-dependent 'auto' 233 234 // sync group -> all tagged members; standalone -> itself 235 const group = gallery.dataset.syncGroup; 236 const members = group 237 ? [...document.querySelectorAll('.video-gallery[data-sync-group]')].filter((g) => g.dataset.syncGroup === group) 238 : [gallery]; 239 const key = laneKey(gallery); 240 // synced members MUST have equal slot counts (wrap uses each member's OWN maxScroll; 241 // Math.min only bounds the shared index) 242 const count = Math.min(...members.map((g) => g.querySelectorAll('.video-slot').length)); 243 if (!(count > 0)) return; 244 if (wrapping.has(key)) return; // lane still animating a boundary wrap 245 246 // cancel pending drag-reconcile timers FIRST: a stale settle timer could read pre-animation 247 // scrollLeft and clobber the index we set (lost increment) 248 members.forEach(cancelSettle); 249 250 // seed from real scroll position on first use 251 const cur = trackedIndex.has(key) 252 ? trackedIndex.get(key) 253 : Math.round(gallery.scrollLeft / (gallerySlotStep(gallery) || 1)); 254 255 // boundary: cycle via wrapSlide (reduced motion: jump); synced members move together; 256 // `wrapping` blocks re-entry until the slide+restore finishes 257 if (dir > 0 && cur === count - 1) { // next on the last slot -> first 258 trackedIndex.set(key, 0); 259 if (window.prefersReducedMotion) { 260 members.forEach((g) => g.scrollTo({ left: 0, behavior: 'instant' })); 261 } else { 262 wrapping.add(key); 263 let pending = members.length; 264 members.forEach((g) => wrapSlide(g, 1, () => { if (--pending === 0) wrapping.delete(key); })); 265 } 266 return; 267 } 268 if (dir < 0 && cur === 0) { // prev on the first slot -> last 269 trackedIndex.set(key, count - 1); 270 if (window.prefersReducedMotion) { 271 members.forEach((g) => g.scrollTo({ left: g.scrollWidth - g.clientWidth, behavior: 'instant' })); 272 } else { 273 wrapping.add(key); 274 let pending = members.length; 275 members.forEach((g) => wrapSlide(g, -1, () => { if (--pending === 0) wrapping.delete(key); })); 276 } 277 return; 278 } 279 280 const next = cur + dir; // interior step (boundaries handled above) 281 trackedIndex.set(key, next); 282 members.forEach((g) => { g.scrollTo({ left: next * gallerySlotStep(g), behavior }); }); 283 }); 284 }); 285 286 // drag/wheel reconcile: after scrolling settles, snap lane index to round(scrollLeft/step) â 287 // valid only while every gallery shows one full-width slot per view 288 document.querySelectorAll('.video-gallery').forEach((g) => { 289 g.addEventListener('scroll', () => { 290 cancelSettle(g); 291 settleTimers.set(g, setTimeout(() => { 292 settleTimers.delete(g); 293 if (wrapping.has(laneKey(g))) return; // mid-wrap scrollLeft is transient â don't reconcile it 294 const s = gallerySlotStep(g); 295 if (s) trackedIndex.set(laneKey(g), Math.round(g.scrollLeft / s)); 296 }, 150)); 297 }, { passive: true }); 298 }); 299}); 300 301// Lazy videos: data-lazy-src/type or <source data-src>; pause off-screen, load on first intersect 302document.addEventListener('DOMContentLoaded', () => { 303 const lazyVideos = document.querySelectorAll('video[data-lazy]'); 304 if (!('IntersectionObserver' in window) || lazyVideos.length === 0) { 305 lazyVideos.forEach(loadVideo); // fallback: load all 306 return; 307 } 308 const io = new IntersectionObserver((entries) => {
309 entries.forEach((entry) => { 310 const v = entry.target; 311 if (entry.isIntersecting) { 312 if (v.dataset.loaded !== 'true') loadVideo(v); 313 if (v.paused && v.dataset.autoplay === 'true' && v.dataset.holding !== '1') v.play().catch(() => { /* autoplay may be blocked */ }); 314 } else if (!v.paused) { 315 v.pause(); 316 } 317 }); 318 }, { threshold: 0.1, rootMargin: '120px' }); 319 lazyVideos.forEach((v) => io.observe(v)); 320}); 321 322function loadVideo(v) { 323 v.querySelectorAll('source[data-src]').forEach((s) => { 324 s.src = s.dataset.src; 325 s.removeAttribute('data-src'); 326 }); 327 v.load(); 328 v.dataset.loaded = 'true'; 329} 330 331// data-holdloop: play once, freeze final frame HOLD_MS, replay (lazy IO honors data-holding) 332// choreographed reveal: Input once â Gen1 â Gen2 â replay; pauses off-screen; reduced-motion shows all 333const SEQ_STEP = 650; // ms between Input-end and Gen 1, and Gen 1 and Gen 2 334const SEQ_HOLD = 2400; // ms to hold on the finished Gen 2 before replaying 335document.addEventListener('DOMContentLoaded', () => { 336 const row = document.querySelector('#app-langtable .lt-figs--titled'); 337 if (!row) return; 338 const figs = [...row.querySelectorAll('.lt-fig')]; // [input, gen1, gen2] 339 if (figs.length < 3) return; 340 const inV = figs[0].querySelector('video'); 341 const g2V = figs[2].querySelector('video'); 342 if (!inV || !g2V) return; 343 344 if (window.prefersReducedMotion) { 345 figs.forEach((f) => f.classList.add('is-seen')); 346 [inV, g2V].forEach((v) => { v.loop = true; v.play().catch(() => {}); }); 347 return; 348 } 349 350 let timers = []; 351 let running = false; 352 const clearT = () => { timers.forEach(clearTimeout); timers = []; }; 353 const stage = (n) => figs.forEach((f, i) => { 354 f.classList.toggle('is-active', i === n); 355 f.classList.toggle('is-seen', i < n); 356 f.classList.toggle('is-dim', i > n); 357 }); 358 const playV = (v) => { try { v.currentTime = 0; } catch (e) { /* not seekable */ } return v.play().catch(() => {}); }; 359 const run = () => { clearT(); running = true; stage(0); playV(inV); }; 360 const stop = () => { clearT(); running = false; inV.pause(); g2V.pause(); }; 361 362 inV.addEventListener('ended', () => { 363 if (!running) return; 364 inV.pause(); 365 timers.push(setTimeout(() => { 366 stage(1); // reveal Gen 1 367 timers.push(setTimeout(() => { stage(2); playV(g2V); }, SEQ_STEP + 400)); // play Gen 2 368 }, SEQ_STEP)); 369 }); 370 g2V.addEventListener('ended', () => { 371 if (!running) return; 372 g2V.pause(); 373 timers.push(setTimeout(run, SEQ_HOLD)); // replay the sequence 374 }); 375 376 const io = new IntersectionObserver((entries) => { 377 entries.forEach((e) => { if (e.isIntersecting) { if (!running) run(); } else { stop(); } }); 378 }, { threshold: 0.25 }); 379 io.observe(row); 380}); 381 382// Capability cards (LEGACY â markup is template-inert): per-card roller + modal nav 383 384const ROLL_MS = 3200; // ms per frame 385const TRANSITION_MS = 550; // CSS transition duration (.cap-card-roll) 386 387document.addEventListener('DOMContentLoaded', () => { 388 const modal = document.getElementById('cap-modal'); 389 if (!modal) return; 390 const stage = modal.querySelector('[data-cap-stage]'); 391 const titleEl = modal.querySelector('.cap-modal-title'); 392 const subEl = modal.querySelector('[data-cap-modal-sub]'); 393 const captionEl = modal.querySelector('[data-cap-modal-caption]'); 394 const counterEl = modal.querySelector('[data-cap-counter]'); 395 const prevBtn = modal.querySelector('[data-cap-prev]'); 396 const nextBtn = modal.querySelector('[data-cap-next]'); 397 const cards = [...document.querySelectorAll('.cap-card')]; 398 if (!stage || cards.length === 0) return; 399 400 // per-card carousel 401 const carousels = cards.map((card, cardIdx) => { 402 const roll = card.querySelector('.cap-card-roll'); 403 const frames = roll ? [...roll.querySelectorAll('.cap-card-frame')] : []; 404 const dots = [...card.querySelectorAll('.cap-card-dots i')]; 405 let idx = 0; 406 let timer = null; 407 let paused = false; 408 409 const apply = ({ animate = true } = {}) => { 410 if (roll) { 411 if (!animate) { 412 // suppress the transition for this update, then restore on next frame 413 const prev = roll.style.transition;
414 roll.style.transition = 'none'; 415 roll.style.transform = `translateX(-${idx * 100}%)`; 416 // force a reflow so the transform takes effect with transition disabled 417 void roll.offsetWidth; 418 roll.style.transition = prev; 419 } else { 420 roll.style.transform = `translateX(-${idx * 100}%)`; 421 } 422 } 423 dots.forEach((d, i) => d.classList.toggle('is-active', i === idx)); 424 }; 425 const advance = () => { 426 if (paused || frames.length < 2) return; 427 if (idx === frames.length - 1) { 428 // wrap last â first as an instant jump (no reverse-slide animation) 429 idx = 0; 430 apply({ animate: false }); 431 } else { 432 idx += 1; 433 apply({ animate: true }); 434 } 435 }; 436 const start = () => { 437 if (window.prefersReducedMotion || frames.length < 2) return; 438 stop(); 439 timer = setInterval(advance, ROLL_MS); 440 }; 441 const stop = () => { if (timer) { clearInterval(timer); timer = null; } }; 442 const pause = () => { paused = true; }; 443 const resume = () => { paused = false; }; 444 445 // Stagger the start of each card so they don't all tick in unison 446 setTimeout(start, cardIdx * 420); 447 448 card.addEventListener('mouseenter', pause); 449 card.addEventListener('mouseleave', resume); 450 card.addEventListener('focusin', pause); 451 card.addEventListener('focusout', resume); 452 453 apply(); 454 return { 455 card, 456 frames, 457 get idx() { return idx; }, 458 set idx(v) { 459 const prev = idx; 460 const n = frames.length; 461 idx = v; 462 // wrap in either direction â jump without animation 463 const isWrap = (prev === n - 1 && v === 0) || (prev === 0 && v === n - 1); 464 apply({ animate: !isWrap }); 465 }, 466 start, 467 stop, 468 }; 469 }); 470 471 // modal 472 let activeCar = null; // carousel object of currently-open card 473 let activeIdx = 0; // currently-shown frame index inside modal 474 let lastTrigger = null; 475 let hintTimer = null; 476 477 const updateNavState = () => { 478 if (!activeCar) return; 479 const n = activeCar.frames.length; 480 counterEl.textContent = `${activeIdx + 1} / ${n}`; 481 // Loop semantics â leave both buttons enabled 482 prevBtn.disabled = false; 483 nextBtn.disabled = false; 484 }; 485 486 const showHint = () => { 487 const hint = stage.querySelector('.cap-modal-hint'); 488 if (!hint) return; 489 hint.classList.add('is-visible'); 490 clearTimeout(hintTimer); 491 hintTimer = setTimeout(() => hint.classList.remove('is-visible'), 3800); 492 }; 493 494 const renderFrame = () => { 495 if (!activeCar) return; 496 const frame = activeCar.frames[activeIdx]; 497 if (!frame) return; 498 const src = frame.dataset.viserSrc?.trim(); 499 const sub = frame.dataset.frameTitle || ''; 500 if (!src) return; 501 502 // reset stage: replace iframe + placeholder 503 stage.querySelector('iframe')?.remove(); 504 stage.querySelector('.cap-modal-loading')?.remove(); 505 stage.querySelector('.cap-modal-hint')?.remove(); 506 507 const loading = document.createElement('div'); 508 loading.className = 'cap-modal-loading'; 509 loading.textContent = 'Loading viser sceneâ¦'; 510 stage.appendChild(loading); 511 512 const iframe = document.createElement('iframe'); 513 iframe.src = src; 514 iframe.setAttribute('title', sub || 'Viser scene'); 515 iframe.setAttribute('allow', 'fullscreen; xr-spatial-tracking'); 516 iframe.addEventListener('load', () => { 517 loading.remove(); 518 showHint(); 519 }); 520 stage.appendChild(iframe); 521 522 // Re-add hint element (above iframe, below arrows) 523 const hint = document.createElement('div'); 524 hint.className = 'cap-modal-hint'; 525 hint.innerHTML = 'Drag to orbit · scroll to zoom · <kbd>â</kbd><kbd>â</kbd> to switch · <kbd>Esc</kbd> to close'; 526 stage.appendChild(hint); 527 528 subEl.textContent = sub; 529 updateNavState(); 530 }; 531 532 const open = (card) => { 533 const car = carousels.find((c) => c.card === card); 534 if (!car) return; 535 activeCar = car; 536 activeIdx = car.idx; // open at the frame currently shown on the card 537 lastTrigger = card; 538 539 titleEl.innerHTML = card.dataset.capName || 'Capability'; 540 captionEl.innerHTML = card.dataset.capBlurb || ''; 541 renderFrame(); 542 543 modal.hidden = false;
544 requestAnimationFrame(() => { 545 modal.classList.add('is-open'); 546 modal.setAttribute('aria-hidden', 'false'); 547 }); 548 document.body.classList.add('cap-modal-open'); 549 modal.querySelector('.cap-modal-close')?.focus({ preventScroll: true }); 550 }; 551 552 const close = () => { 553 if (!modal.classList.contains('is-open')) return; 554 modal.classList.remove('is-open'); 555 modal.setAttribute('aria-hidden', 'true'); 556 document.body.classList.remove('cap-modal-open'); 557 clearTimeout(hintTimer); 558 setTimeout(() => { 559 modal.hidden = true; 560 stage.querySelector('iframe')?.remove(); 561 stage.querySelector('.cap-modal-loading')?.remove(); 562 stage.querySelector('.cap-modal-hint')?.remove(); 563 }, 220); 564 if (lastTrigger) { 565 lastTrigger.focus({ preventScroll: true }); 566 lastTrigger = null; 567 } 568 activeCar = null; 569 }; 570 571 const stepFrame = (delta) => { 572 if (!activeCar) return; 573 const n = activeCar.frames.length; 574 activeIdx = (activeIdx + delta + n) % n; 575 // also advance the underlying card carousel so they stay in sync 576 activeCar.idx = activeIdx; 577 renderFrame(); 578 }; 579 580 // Wire clicks 581 cards.forEach((card) => { 582 card.addEventListener('click', () => open(card)); 583 }); 584 modal.querySelectorAll('[data-cap-close]').forEach((el) => { 585 el.addEventListener('click', close); 586 }); 587 prevBtn?.addEventListener('click', (e) => { e.stopPropagation(); stepFrame(-1); }); 588 nextBtn?.addEventListener('click', (e) => { e.stopPropagation(); stepFrame(+1); }); 589 590 document.addEventListener('keydown', (e) => { 591 if (!modal.classList.contains('is-open')) return; 592 if (e.key === 'Escape') { e.preventDefault(); close(); } 593 else if (e.key === 'ArrowRight') { e.preventDefault(); stepFrame(+1); } 594 else if (e.key === 'ArrowLeft') { e.preventDefault(); stepFrame(-1); } 595 }); 596}); 597 598// Copy-to-clipboard (data-copy-target) 599document.addEventListener('DOMContentLoaded', () => { 600 document.querySelectorAll('[data-copy-target]').forEach((btn) => { 601 btn.addEventListener('click', async () => { 602 const sel = btn.dataset.copyTarget; 603 const node = sel ? document.querySelector(sel) : null; 604 if (!node) return; 605 const text = node.textContent.trim(); 606 try { 607 await navigator.clipboard.writeText(text); 608 } catch { 609 const r = document.createRange(); 610 r.selectNode(node); 611 const s = window.getSelection(); 612 s.removeAllRanges(); 613 s.addRange(r); 614 document.execCommand('copy'); 615 s.removeAllRanges(); 616 } 617 const original = btn.textContent; 618 btn.textContent = 'Copied'; 619 btn.classList.add('copied'); 620 setTimeout(() => { 621 btn.textContent = original; 622 btn.classList.remove('copied'); 623 }, 1400); 624 }); 625 }); 626}); 627 628// Retargeting comparison: 2 examples in data-ex1/ex2; load on first intersect, swap on demand 629(() => { 630 const fig = document.querySelector('#app-retargeting .rt-cmp'); 631 if (!fig) return; 632 const vids = [...fig.querySelectorAll('video[data-cmp]')]; 633 const prevBtn = document.querySelector('[data-cmp-prev]'); 634 const nextBtn = document.querySelector('[data-cmp-next]'); 635 const ind = document.querySelector('[data-cmp-ind]'); 636 if (!vids.length || !prevBtn || !nextBtn) return; 637 const N = 2; 638 let cur = 1; 639 let started = false; 640 const apply = (n) => { 641 cur = ((n - 1 + N) % N) + 1; 642 vids.forEach((v) => { 643 const url = v.getAttribute('data-ex' + cur); 644 if (!url || v.getAttribute('src') === url) return; 645 v.setAttribute('src', url); 646 v.load(); 647 v.play().catch(() => {}); 648 }); 649 if (ind) ind.textContent = cur + ' / ' + N; 650 }; 651 if ('IntersectionObserver' in window) { 652 const io = new IntersectionObserver((entries) => { 653 entries.forEach((e) => { 654 if (e.isIntersecting && !started) { started = true; apply(1); io.disconnect(); } 655 }); 656 }, { rootMargin: '200px' }); 657 io.observe(fig); 658 } else { 659 apply(1); 660 } 661 prevBtn.addEventListener('click', () => apply(cur - 1)); 662 nextBtn.addEventListener('click', () => apply(cur + 1)); 663})(); 664 665// In-the-wild: same pattern via data-wex1/2 + captions data-wcap1/2 666(() => {
667 const fig = document.querySelector('#app-retargeting .rt-wild'); 668 if (!fig) return; 669 const vids = [...fig.querySelectorAll('video[data-wild]')]; 670 const prevBtn = fig.querySelector('[data-wild-prev]'); 671 const nextBtn = fig.querySelector('[data-wild-next]'); 672 const ind = fig.querySelector('[data-wild-ind]'); 673 const caps = [...fig.querySelectorAll('[data-wild-cap]')]; 674 if (!vids.length || !prevBtn || !nextBtn) return; 675 const N = 2; 676 let cur = 1; 677 let started = false; 678 let io = null; 679 const apply = (n) => { 680 cur = ((n - 1 + N) % N) + 1; 681 vids.forEach((v) => { 682 const url = v.getAttribute('data-wex' + cur); 683 if (!url || v.getAttribute('src') === url) return; 684 v.setAttribute('src', url); 685 v.load(); 686 v.play().catch(() => {}); 687 }); 688 caps.forEach((c) => { 689 const t = c.getAttribute('data-wcap' + cur); 690 if (t) c.textContent = t; 691 }); 692 if (ind) ind.textContent = cur + ' / ' + N; 693 }; 694 // mark started + stop the lazy observer so a later intersect can't revert a user click 695 const begin = () => { if (!started) { started = true; if (io) io.disconnect(); } }; 696 if ('IntersectionObserver' in window) { 697 io = new IntersectionObserver((entries) => { 698 entries.forEach((e) => { 699 if (e.isIntersecting && !started) { begin(); apply(1); } 700 }); 701 }, { rootMargin: '200px' }); 702 io.observe(fig); 703 } else { 704 apply(1); 705 } 706 prevBtn.addEventListener('click', () => { begin(); apply(cur - 1); }); 707 nextBtn.addEventListener('click', () => { begin(); apply(cur + 1); }); 708})();
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.