/** * Results Page Controller * 탭 전환 및 리포트 페이지 애니메이션 * * @version 2.1.0 (Simplified) */ (function() { 'use strict'; // ============================================ // 설정 // ============================================ const config = { // 선택자 selectors: { wrap: '.results-wrap', tabs: '.tab-list li a', panels: '.tab-content', pages: '.report-page' }, // 탭 ID tabIds: ['key-natural', 'key-social', 'key-cost'], // 애니메이션 설정 animation: { delay: 100, // 각 페이지 간격 (ms) duration: 600, // 애니메이션 지속 (ms) distance: 120 // 시작 위치 (px) }, // 스크롤 탭 전환 scrollTab: { enabled: true, distance: '150%' // 핀 유지 거리 } }; // ============================================ // 유틸리티 // ============================================ const $ = (sel, ctx = document) => ctx.querySelector(sel); const $$ = (sel, ctx = document) => ctx.querySelectorAll(sel); const hasGSAP = () => typeof gsap !== 'undefined' && typeof ScrollTrigger !== 'undefined'; // Debounce const debounce = (fn, ms) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; }; // Throttle const throttle = (fn, ms) => { let waiting = false; return (...args) => { if (!waiting) { fn(...args); waiting = true; setTimeout(() => waiting = false, ms); } }; }; // ============================================ // 탭 컨트롤러 // ============================================ const TabController = { wrap: null, tabs: null, panels: null, init() { this.wrap = $(config.selectors.wrap); if (!this.wrap) return; this.tabs = $$(config.selectors.tabs); this.panels = $$(config.selectors.panels); this.setupEvents(); }, setupEvents() { this.tabs.forEach((tab, i) => { // 클릭 tab.addEventListener('click', (e) => { e.preventDefault(); this.switch(i); }); // 키보드 tab.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.switch(i); } }); }); }, switch(index) { const targetId = config.tabIds[index]; const target = document.getElementById(targetId); if (!target) return; // 모든 패널 숨김 this.panels.forEach(panel => { if (panel !== target) { panel.classList.remove('on'); Animation.reset(panel); } }); // 선택된 패널 표시 target.classList.add('on'); // 탭 상태 업데이트 this.tabs.forEach((tab, i) => { const li = tab.closest('li'); li.classList.toggle('on', i === index); tab.setAttribute('aria-selected', i === index); }); // 애니메이션 실행 setTimeout(() => Animation.run(target), 50); // 스크롤 탭 인덱스 동기화 if (ScrollTab.enabled) { ScrollTab.currentIndex = index; } } }; // ============================================ // 애니메이션 // ============================================ const Animation = { animated: new Set(), init() { if (!hasGSAP()) return; const panels = $$(config.selectors.panels); // 각 패널에 스크롤 트리거 설정 panels.forEach(panel => { ScrollTrigger.create({ trigger: panel, start: 'top center', onEnter: () => this.onEnter(panel), onEnterBack: () => this.onEnter(panel) }); }); // 초기 상태 확인 this.checkInitial(); }, onEnter(panel) { if (!panel.classList.contains('on')) return; this.run(panel); }, checkInitial() { const active = $('.tab-content.on'); if (!active) return; const rect = active.getBoundingClientRect(); if (rect.top <= window.innerHeight / 2) { this.run(active); } }, run(panel) { const pages = $$(config.selectors.pages, panel); if (!pages.length) return; const { delay, duration, distance } = config.animation; if (hasGSAP()) { // GSAP 애니메이션 gsap.set(pages, { opacity: 0, y: distance }); gsap.to(pages, { opacity: 1, y: 0, duration: duration / 1000, stagger: delay / 1000, ease: 'power2.out' }); } else { // CSS 애니메이션 pages.forEach((page, i) => { page.style.cssText = ` opacity: 0; transform: translateY(${distance}px); transition: none; `; requestAnimationFrame(() => { setTimeout(() => { page.style.cssText = ` opacity: 1; transform: translateY(0); transition: opacity ${duration}ms ease-out, transform ${duration}ms ease-out; `; }, i * delay); }); }); } this.animated.add(panel.id); }, reset(panel) { const pages = $$(config.selectors.pages, panel); if (!pages.length) return; const { distance } = config.animation; if (hasGSAP()) { gsap.set(pages, { opacity: 0, y: distance }); } else { pages.forEach(page => { page.style.cssText = ` opacity: 0; transform: translateY(${distance}px); transition: none; `; }); } } }; // ============================================ // 스크롤 탭 // ============================================ const ScrollTab = { enabled: false, currentIndex: -1, trigger: null, init() { if (!config.scrollTab.enabled || !hasGSAP()) return; const wrap = $(config.selectors.wrap); if (!wrap) return; const tabCount = config.tabIds.length; this.trigger = ScrollTrigger.create({ trigger: wrap, start: 'top top', end: `+=${config.scrollTab.distance}`, pin: true, invalidateOnRefresh: true, onUpdate: throttle((self) => { const index = Math.min( Math.floor(self.progress * tabCount), tabCount - 1 ); if (index !== this.currentIndex && index >= 0) { this.currentIndex = index; TabController.switch(index); } }, 50) }); this.enabled = true; } }; // ============================================ // 리사이즈 핸들러 // ============================================ const ResizeHandler = { init() { window.addEventListener('resize', debounce(() => { if (hasGSAP()) { ScrollTrigger.refresh(); } }, 250)); } }; // ============================================ // 페이지 로드 // ============================================ const PageLoad = { init() { // 스크롤 복원 차단 if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; } // 최상단으로 if (window.scrollY > 0) { window.scrollTo(0, 0); } // 로딩 완료 window.addEventListener('load', () => { document.body.classList.add('loaded'); if (hasGSAP()) { ScrollTrigger.refresh(); } }); } }; // ============================================ // 초기화 // ============================================ const init = () => { // 즉시 실행 PageLoad.init(); // DOM 준비 후 const start = () => { if (hasGSAP()) { gsap.registerPlugin(ScrollTrigger); } TabController.init(); Animation.init(); ScrollTab.init(); ResizeHandler.init(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } }; // ============================================ // 전역 API // ============================================ window.ResultsPageController = { switchTab: (i) => TabController.switch(i), refresh: () => hasGSAP() && ScrollTrigger.refresh(), config: config }; // 시작 init(); })();