PageSourceSearch

https://jiahuilei.com/projects/wmm/scripts/main.js?v=10

js jiahuilei.com collected 2026-10-03 22:16:18 UTC 28,153 bytes, 708 lines download raw bytes

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.