PageSourceSearch

https://www.hotel-kawakyu.jp/wp-content/themes/lm-child/js/pages/kawakyu-top/journey/app.js?ver=1789971682

js hotel-kawakyu.jp collected 2026-09-30 15:51:20 UTC 22,139 bytes, 473 lines download raw bytes

1import { scenes, motion } from './scene-data.js';
2import { createLiquid } from './liquid.js';
3
4const root = document.querySelector('.discover');
5const continuousScroll = false;
6const find = selector => root.querySelector(selector);
7const stage = find('.discover__stage');
8const reduced = matchMedia('(prefers-reduced-motion: reduce)');
9const mobileVideo = matchMedia('(max-width: 768px)');
10const canvas = find('canvas'), fallback = find('.discover__fallback');
11const pool = find('.discover__media-pool');
12const previews = root.querySelectorAll('[data-offset]');
13const videos = new Map(), posters = new Map(), pending = new Map();
14const cancelLoad = new WeakMap();
15let renderer, activeMedia, transition, tween, current = 0, busy = false, epoch = 0;
16let inView = false, pinned = false, frame = 0, trigger;
17let consumed = false, lastWheel = 0, wheelTotal = 0, touch = null;
18let pinPosition = 0, previousScroll = window.scrollY;
19let desiredIndex = 0;
20let autoplayTimer = 0;
21const autoplayDelay = 5000;
22const debugMode = new URLSearchParams(window.location.search).has('discover-video-debug');
23const debugHistory = [];
24const debugPanel = debugMode ? document.createElement('pre') : null;
25
26if (debugPanel) {
27  debugPanel.className = 'discover__debug';
28  debugPanel.setAttribute('aria-live', 'polite');
29  root.append(debugPanel);
30}
31function debug(message) {
32  if (!debugPanel) return;
33  const stamp = new Date().toLocaleTimeString('ja-JP', { hour12: false });
34  debugHistory.unshift(`${stamp}  ${message}`);
35  debugPanel.textContent = debugHistory.slice(0, 7).join('\n');
36  console.info(`[Discover video] ${message}`);
37}
38
39// SPでは動画→WebGLテクスチャ転送を行わない。動画要素をそのまま表示し、
40// CSSのフェードで切り替えることでGPU負荷と描画ループを抑える。
41function configureRenderer() {
42  if (mobileVideo.matches || reduced.matches) {
43    renderer = null;
44    root.classList.remove('is-webgl');
45    return;
46  }
47  if (!renderer) {
48    try { renderer = createLiquid(canvas, motion); } catch { renderer = null; }
49  }
50  root.classList.toggle('is-webgl', !!renderer);
51}
52
53const portal = find('.discover__portal'), cursor = find('.discover__cursor');
54const pointerQuery = matchMedia('(hover: hover) and (pointer: fine)');
55function hideCursor() {
56  root.classList.remove('is-pointer');
57}
58function moveCursor(event) {
59  if (!pointerQuery.matches || event.pointerType === 'touch') { hideCursor(); return; }
60  // カーソルの配置先と同じstageを基準にし、pin前後でも見出しの高さが座標へ混ざらないようにする。
61  const rect = stage.getBoundingClientRect();
62  const x = event.clientX - rect.left, y = event.clientY - rect.top;
63  gsap.set(cursor, { x, y });
64  root.classList.add('is-pointer');
65}
66portal.addEventListener('pointerenter', moveCursor);
67portal.addEventListener('pointermove', moveCursor);
68portal.addEventListener('pointerleave', hideCursor);
69window.addEventListener('blur', hideCursor);
70root.addEventListener('keydown', hideCursor);
71pointerQuery.addEventListener('change', hideCursor);
72reduced.addEventListener('change', hideCursor);
73
74// 上下両方向とも4辺の値を揃えて補間し、逆方向のワイプが即時表示にならないようにする。
75previews.forEach(preview => preview.append(preview.querySelector('img').cloneNode()));
76function animatePreviews(target, direction) {
77  previews.forEach(preview => {
78    const [oldImage, newImage] = preview.querySelectorAll('img');
79    const index = (target + Number(preview.dataset.offset) + scenes.length) % scenes.length;
80    const closed = Number(preview.dataset.offset) < 0 ? 'inset(0% 0% 100% 0%)' : 'inset(100% 0% 0% 0%)';
81    newImage.src = scenes[index].poster;
82    gsap.set(oldImage, { zIndex: direction > 0 ? 2 : 1, clipPath:'inset(0% 0% 0% 0%)' });
83    gsap.set(newImage, { zIndex: direction > 0 ? 1 : 2, clipPath: direction > 0 ? 'inset(0% 0% 0% 0%)' : closed });
84    gsap.to(direction > 0 ? oldImage : newImage, { clipPath: direction > 0 ? closed : 'inset(0% 0% 0% 0%)', duration:reduced.matches ? 0 : .7, delay:reduced.matches ? 0 : .08, ease:'power4.inOut' });
85  });
86}
87function updateInfo(updatePreviews = true) {
88  const scene = scenes[current];
89  find('.discover__title').textContent = scene.title;
90  find('.discover__name').textContent = scene.ja;
91  find('.discover__lead').textContent = scene.lead;
92  find('.discover__portal').href = scene.href;
93  find('.discover__portal').setAttribute('aria-label', `${scene.title}のページへ`);
94  find('.discover__counter').textContent = `${String(current + 1).padStart(2, '0')}/06`;
95  find('.discover__status').textContent = `${current + 1} / 6 ${scene.title}`;
96  root.dataset.index = String(current);
97  if (updatePreviews) previews.forEach(preview => {
98    // サムネイルは端の先も選択できるよう循環表示し、スクロール本体は端で退出する。
99    const next = (current + Number(preview.dataset.offset) + scenes.length) % scenes.length;
100    preview.querySelectorAll('img').forEach(image => { gsap.killTweensOf(image); image.src = scenes[next].poster; gsap.set(image,{clipPath:'inset(0% 0% 0% 0%)',zIndex:0}); });
101    preview.dataset.target = String(next);
102    preview.setAttribute('aria-label', `${scenes[next].title}を表示`);
103  });
104}
105previews.forEach(preview => preview.addEventListener('click', () => {
106  goTo(Number(preview.dataset.target), Math.sign(Number(preview.dataset.offset)));
107}));
108function poster(index) {
109  if (!posters.has(index)) {
110    posters.set(index, new Promise((resolve, reject) => {
111      const image = new Image();
112      image.onload = () => resolve(image);
113      image.onerror = () => reject(new Error(`ポスターを読み込めません: ${scenes[index].poster}`));
114      image.src = scenes[index].poster;
115    }));
116  }
117  return posters.get(index);
118}
119// 取り消した古い読込の完了通知が、再進入後の同じ施設の動画を消さないようにする。
120function unload(index, video) {
121  cancelLoad.get(video)?.();
122  video.pause(); video.removeAttribute('src'); video.load(); video.remove();
123  if (videos.get(index) === video) videos.delete(index);
124}
125function trimVideos() {
126  // iPhoneでは切替直後に次の動画を破棄・再生成すると読込が競合しやすい。
127  // 現在と進行方向の次の1本だけを保持し、3本以上のデコーダー確保は避ける。
128  const retained = mobileVideo.matches ? [current, current + 1] : [current];
129  const keep = new Set(retained.filter(index => index >= 0 && index < scenes.length));
130  if (transition) {
131    keep.add(transition.from);
132    keep.add(transition.to);
133  }
134  videos.forEach((video, index) => { if (!keep.has(index)) unload(index, video); });
135}
136function resumeVideo(video) {
137  if (!(video instanceof HTMLVideoElement)) return;
138  // iOS Safariでは、DOM移動後に属性とplay()の両方を明示しないと
139  // 最初のフレームで停止する端末がある。
140  video.muted = true;
141  video.defaultMuted = true;
142  video.autoplay = true;
143  video.playsInline = true;
144  video.setAttribute('muted', '');
145  video.setAttribute('autoplay', '');
146  video.setAttribute('playsinline', '');
147  if (inView && !document.hidden && !reduced.matches && video.isConnected) {
148    video.play()
149      .then(() => debug(`play OK #${Number(video.dataset.journeyIndex) + 1} / readyState:${video.readyState}`))
150      .catch(error => debug(`play NG #${Number(video.dataset.journeyIndex) + 1} / ${error.name}: ${error.message}`));
151  }
152}
153function syncVideos() {
154  videos.forEach((video, index) => {
155    // 先読み用の動画は再生しない。現在表示中と、遷移中の2本だけを再生する。
156    const shouldPlay = inView
157      && !document.hidden
158      && !reduced.matches
159      && (index === current || Boolean(transition && (index === transition.from || index === transition.to)));
160    if (shouldPlay) resumeVideo(video);
161    else video.pause();
162  });
163}
164function preloadNext() {
165  // PCは従来どおりWebGLの切替時にだけ読み込む。Safari対策はSPに限定する。
166  if (!mobileVideo.matches || !inView || reduced.matches) return;
167  const nextIndex = current + 1;
168  if (nextIndex >= scenes.length || videos.has(nextIndex) || pending.has(nextIndex)) return;
169  debug(`preload start #${nextIndex + 1}`);
170  media(nextIndex).then(() => {
171    // 先読み完了後も次の動画は停止したまま保持する。
172    trimVideos();
173    syncVideos();
174    debug(`preload ready #${nextIndex + 1}`);
175  });
176}
177mobileVideo.addEventListener('change', () => {
178  // 回転やリサイズで768px境界をまたいだ場合も、保持中の動画を作り直す。
179  configureRenderer();
180  videos.forEach((video, index) => unload(index, video));
181  pending.clear();
182  if (!inView) return;
183  poster(current).then(showFallback).catch(() => {});
184  media(current).then(next => {
185    if (!transition) {
186      activeMedia = next;
187      syncVideos();
188      draw();
189      requestFrame();
190    }
191  });
192});
193async function media(index) {
194  const still = await poster(index);
195  if (reduced.matches) return still;
196  if (pending.has(index)) return pending.get(index);
197  if (videos.has(index)) return videos.get(index);
198  const promise = new Promise(resolve => {
199    const video = document.createElement('video');
200    video.muted = true; video.defaultMuted = true; video.autoplay = true; video.loop = true; video.playsInline = true; video.preload = 'metadata';
201    video.setAttribute('muted', ''); video.setAttribute('autoplay', ''); video.setAttribute('playsinline', '');
202    video.dataset.journeyIndex = String(index);
203    video.poster = scenes[index].poster; pool.append(video); videos.set(index, video);
204    // 配信失敗や省データ環境でもpin内に閉じ込めないよう、期限内にポスター表示へ戻す。
205    const timeout = setTimeout(() => finish(false), 6000);
206    let finished = false;
207    function finish(ok) {
208      if (finished) return;
209      finished = true; cancelLoad.delete(video);
210      if (pending.get(index) === promise) pending.delete(index);
211      clearTimeout(timeout); video.onloadeddata = null; video.onerror = null;
212      if (!ok) { unload(index, video); resolve(still); }
213      else { syncVideos(); resolve(video); }
214    }
215    cancelLoad.set(video, () => finish(false));
216    let triedPcFallback = false;
217    const source = mobileVideo.matches ? scenes[index].videoSp : scenes[index].video;
218    video.onloadeddata = () => {
219      debug(`loadeddata #${index + 1} / ${source.split('/').pop()} / readyState:${video.readyState}`);
220      finish(true);
221    };
222    video.onerror = () => {
223      debug(`load NG #${index + 1} / ${video.currentSrc || source}`);
224      // SP素材がまだ未配置でもJourneyをポスター表示にせず、PC動画を使う。
225      if (source === scenes[index].videoSp && !triedPcFallback) {
226        triedPcFallback = true;
227        debug(`PCへフォールバック #${index + 1} / ${scenes[index].video.split('/').pop()}`);
228        video.src = scenes[index].video;
229        video.load();
230        return;
231      }
232      finish(false);
233    };
234    debug(`load start #${index + 1} / ${source}`);
235    video.src = source;
236  });
237  pending.set(index, promise);
238  return promise;
239}
240function showFallback(source) {
241  fallback.replaceChildren(source);
242  if (source instanceof HTMLVideoElement) {
243    debug(`display #${Number(source.dataset.journeyIndex) + 1} / ${source.currentSrc || source.src}`);
244    requestAnimationFrame(() => resumeVideo(source));
245  }
246}
247function restartFallbackTransition() {
248  fallback.classList.remove('is-transitioning');
249  // 同一クラスを続けて付与した場合も、各スライドでアニメーションを再開する。
250  void fallback.offsetWidth;
251  fallback.classList.add('is-transitioning');
252}
253function draw() {
254  if (!activeMedia || !renderer) return;
255  const a = transition?.a || activeMedia, b = transition?.b || activeMedia;
256  const p = transition?.progress || 0;
257  try { renderer.draw(a, b, p, transition?.direction || 1); }
258  catch { renderer = null; root.classList.remove('is-webgl'); showFallback(p < .5 ? a : b); }
259}
260function render() {
261  frame = 0;
262  if (!inView || document.hidden) return;
263  draw();
264  if (renderer && (transition || activeMedia instanceof HTMLVideoElement)) requestFrame();
265}
266function requestFrame() { if (renderer && !frame && inView && !document.hidden) frame = requestAnimationFrame(render); }
267function setBusy(value) {
268  busy = value; root.setAttribute('aria-busy', String(value));
269  // 遷移中もキーボードフォーカスを保ち、追加入力はgoToのbusy判定で抑える。
270  previews.forEach(preview => { preview.setAttribute('aria-disabled', String(value)); });
271  if (value) clearAutoplay();
272  else scheduleAutoplay();
273}
274function clearAutoplay() {
275  if (!autoplayTimer) return;
276  window.clearTimeout(autoplayTimer);
277  autoplayTimer = 0;
278}
279function scheduleAutoplay() {
280  clearAutoplay();
281  if (reduced.matches || !inView || document.hidden || busy) return;
282  autoplayTimer = window.setTimeout(() => {
283    autoplayTimer = 0;
284    if (!busy && inView && !document.hidden) {
285      goTo((current + 1) % scenes.length, 1);
286    }
287  }, autoplayDelay);
288}
289function syncSceneFromScroll(progress) {
290  if (typeof progress === 'number') desiredIndex = Math.min(scenes.length - 1, Math.floor(progress * scenes.length));
291  if (continuousScroll && !busy && desiredIndex !== current) goTo(desiredIndex, Math.sign(desiredIndex - current) || 1);
292}
293async function show(index) {
294  const token = ++epoch;
295  tween?.kill(); transition = null; activeMedia = null; setBusy(true);
296  current = index; updateInfo(); root.classList.remove('is-switching'); fallback.classList.remove('is-transitioning');
297  trimVideos();
298  try {
299    const next = await media(index);
300    // スクロールバー操作や再進入で読み込み先が変わっても、古い結果を表示しない。
301    if (token !== epoch) return;
302    activeMedia = next;
303    if (!renderer) showFallback(next);
304    syncVideos(); requestFrame();
305  } catch (error) {
306    find('.discover__status').textContent = '映像を読み込めませんでした。リンクから施設ページへ進めます。';
307    console.warn(error);
308  } finally {
309    if (token === epoch) {
310      setBusy(false);
311      trimVideos();
312      preloadNext();
313    }
314  }
315}
316async function step(direction) {
317  if (busy) return;
318  const target = current + direction;
319  if (target < 0 || target >= scenes.length) { leave(direction); return; }
320  return goTo(target, direction);
321}
322async function goTo(target, direction) {
323  if (busy || target === current) return;
324  const token = ++epoch;
325  setBusy(true);
326  try {
327    const next = await media(target);
328    if (token !== epoch) return;
329    transition = { from: current, to: target, a: activeMedia || next, b: next, progress: 0, direction };
330    animatePreviews(target, direction);
331    root.classList.add('is-switching');
332    if (!renderer) restartFallbackTransition();
333    let swapped = false;
334    tween = gsap.to(transition, {
335      progress: 1, duration: reduced.matches ? 0 : motion.duration, ease: t => t * t * (3 - 2 * t),
336      onUpdate() {
337        if (transition.progress >= .5 && !swapped) {
338          swapped = true; current = target; updateInfo(false); root.classList.remove('is-switching');
339          if (!renderer) showFallback(next);
340        }
341        draw();
342      },
343      onComplete() {
344        current = target; activeMedia = next; transition = null;
345        updateInfo(); root.classList.remove('is-switching');
346        if (!renderer) showFallback(next);
347        fallback.classList.remove('is-transitioning');
348        setBusy(false); trimVideos(); syncVideos(); preloadNext(); requestFrame();
349      },
350    });
351    syncVideos(); requestFrame();
352  } catch (error) {
353    setBusy(false); trimVideos(); find('.discover__status').textContent = '読み込みを再試行してください。';
354    console.warn(error);
355  }
356}
357function enter(direction, self) {
358  pinned = true; root.dataset.pinned = 'true';
359  pinPosition = Math.max(self.start + 1, Math.min(window.scrollY, self.end - 1));
360  // 到着した操作は固定の導入として消費し、次の明確な操作で通常スクロールへ戻す。
361  consumed = true; lastWheel = performance.now(); wheelTotal = 0;
362  if (touch) touch.used = true;
363}
364function leave(direction) {
365  if (continuousScroll) return;
366  pinned = false;
366 root.dataset.pinned = 'false'; consumed = true;
367  // pinの予約余白だけを消化する。端点の描画位置は同じなので、続く実入力で通常スクロールへ出られる。
368  // 入場時の自動スナップには使用しない。
369  moveScroll(direction > 0 ? trigger.end + 1 : trigger.start - 1);
370}
371function moveScroll(position) {
372  previousScroll = position;
373  if (window.kawakyuSmoothScroll) {
374    window.kawakyuSmoothScroll.scrollTo(position, { immediate: true, force: true });
375  } else {
376    trigger.scroll(position);
377  }
378  ScrollTrigger.update();
379}
380function entryPosition(from, to) {
381  if (from <= trigger.start && to > trigger.start) return trigger.start + 1;
382  if (from >= trigger.end && to < trigger.end) return trigger.end - 1;
383  return null;
384}
385// 一度の入力がpin区間全体を越える場合も、実際に横切った入口で止める。
386// タッチの慣性はtouchmoveのキャンセルだけでは止まらないため、pin中の座標も保持する。
387window.addEventListener('scroll', () => {
388  if (continuousScroll) return;
389  if (!trigger) return;
390  const position = pinned ? pinPosition : entryPosition(previousScroll, window.scrollY);
391  if (position !== null && Math.abs(window.scrollY - position) > .5) moveScroll(position);
392  previousScroll = window.scrollY;
393}, { passive: true });
394// 明示的なページ内リンクは通常の移動を優先し、pinの通過補正に捕まえない。
395document.addEventListener('click', event => {
396  const link = event.target.closest('a[href^="#"]');
397  const target = link && document.getElementById(link.hash.slice(1));
398  if (!target || root.contains(target)) return;
399  pinned = false; root.dataset.pinned = 'false';
400  previousScroll = window.scrollY + target.getBoundingClientRect().top;
401});
402function atEdge(direction) { return direction < 0 ? current === 0 : current === scenes.length - 1; }
403window.addEventListener('wheel', event => {
404  if (document.documentElement.classList.contains('is-hamburger-menu-open')) return;
405  if (continuousScroll) return;
406  if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
407  const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? innerHeight : 1);
408  const entry = trigger && !pinned ? entryPosition(window.scrollY, window.scrollY + delta) : null;
409  if (entry !== null && event.cancelable) {
410    event.preventDefault(); moveScroll(entry); return;
411  }
412  const now = performance.now();
413  if (now - lastWheel > 240 && !busy) { consumed = false; wheelTotal = 0; }
414  lastWheel = now;
415  if (!pinned || !event.deltaY) return;
416  if (!busy && !consumed) { leave(Math.sign(event.deltaY)); return; }
417  if (event.cancelable) event.preventDefault();
418}, { passive: false });
419window.addEventListener('touchstart', event => {
420  if (document.documentElement.classList.contains('is-hamburger-menu-open')) { touch = null; return; }
421  touch = event.touches.length === 1 ? { x: event.touches[0].clientX, y: event.touches[0].clientY, used: busy, blocked: false } : null;
422}, { passive: true });
423window.addEventListener('touchmove', event => {
424  if (document.documentElement.classList.contains('is-hamburger-menu-open')) return;
425  if (continuousScroll) return;
426  if (!touch || event.touches.length !== 1 || !pinned) return;
427  const delta = touch.y - event.touches[0].clientY;
428  if (Math.abs(event.touches[0].clientX - touch.x) > Math.abs(delta)) return;
429  if (!touch.used && !busy && !touch.blocked) {
430    if (Math.abs(delta) > 4) { touch.used = true; leave(Math.sign(delta)); }
431    return;
432  }
433  if (event.cancelable) { event.preventDefault(); touch.blocked = true; }
434}, { passive: false });
435window.addEventListener('touchend', () => { touch = null; }, { passive: true });
436window.addEventListener('touchcancel', () => { touch = null; }, { passive: true });
437window.addEventListener('keydown', event => {
438  if (continuousScroll) return;
439  if (!pinned || event.altKey || event.ctrlKey || event.metaKey || /INPUT|TEXTAREA|SELECT/.test(event.target.tagName)) return;
440  // サムネイルのSpaceはbutton本来の選択操作へ渡し、次スライド操作と重ねない。
441  if (event.key === ' ' && event.target.closest('button')) return;
442  const direction = ['ArrowDown', 'PageDown', ' '].includes(event.key) ? (event.shiftKey ? -1 : 1)
443    : ['ArrowUp', 'PageUp'].includes(event.key) ? -1 : 0;
444  if (!direction) return;
445  event.preventDefault();
446  if (busy || event.repeat) return;
447  leave(direction);
448  window.scrollBy(0, direction * 80);
449});
450
451configureRenderer();
452canvas.addEventListener('webglcontextlost', event => {
453  event.preventDefault(); renderer = null; root.classList.remove('is-webgl');
454  if (activeMedia) showFallback(transition && transition.progress >= .5 ? transition.b : activeMedia);
455});
456new IntersectionObserver(entries => {
457  inView = entries[0].isIntersecting;
458  syncVideos(); requestFrame(); scheduleAutoplay();
459}, { threshold: 0 }).observe(root);
460document.addEventListener('visibilitychange', () => { syncVideos(); requestFrame(); scheduleAutoplay(); });
461new ResizeObserver(() => { draw(); requestFrame(); }).observe(root);
462reduced.addEventListener('change', () => { configureRenderer(); show(current); });
463
464gsap.registerPlugin(ScrollTrigger);
465// Journeyへ入る際だけ短く固定し、次の明確な操作で上下のページセクションへ進める。
466trigger = ScrollTrigger.create({
467  id: 'shi142-discover', trigger: stage, pin: stage, start: 'top top', end: '+=120',
468  onEnter: self => enter(1, self), onEnterBack: self => enter(-1, self),
469  onLeave: () => { pinned = false; root.dataset.pinned = 'false'; },
470  onLeaveBack: () => { pinned = false; root.dataset.pinned = 'false'; },
471});
472updateInfo(); show(current);
473document.fonts.ready.then(() => ScrollTrigger.refresh());

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.