317 lines
9.7 KiB
JavaScript
317 lines
9.7 KiB
JavaScript
/**
|
|
* Layout Fix Left Controller
|
|
* 스크롤 기반 타이틀 전환 및 섹션 네비게이션 공통 모듈
|
|
* analysis, primary 페이지에서 공통 사용
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ============================================
|
|
// Configuration
|
|
// ============================================
|
|
const DEFAULT_CONFIG = {
|
|
SELECTORS: {
|
|
titles: '.js-fixLeft-tit > li',
|
|
sections: '.js-fixLeft-secs > article, .js-fixLeft-secs > div'
|
|
},
|
|
SCROLL: {
|
|
triggerLine: 'center center',
|
|
offsetY: 100,
|
|
bottomThreshold: 30
|
|
},
|
|
ANIMATION: {
|
|
duration: 0.6
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// Utility Functions
|
|
// ============================================
|
|
const Utils = {
|
|
$(selector) {
|
|
return document.querySelector(selector);
|
|
},
|
|
|
|
$$(selector) {
|
|
return document.querySelectorAll(selector);
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// Layout Fix Controller Class
|
|
// ============================================
|
|
class LayoutFixController {
|
|
constructor(config = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
this.keySections = [];
|
|
this.keyData = [];
|
|
this.lastIndex = 0;
|
|
}
|
|
|
|
/**
|
|
* 초기화
|
|
* @param {string|Array} keySelector - key 섹션 선택자 (문자열 또는 배열)
|
|
*/
|
|
init(keySelector) {
|
|
// GSAP 및 ScrollTrigger 확인
|
|
if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
|
|
console.warn('[LayoutFixController] GSAP or ScrollTrigger not loaded');
|
|
return;
|
|
}
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
if (typeof ScrollToPlugin !== 'undefined') {
|
|
gsap.registerPlugin(ScrollToPlugin);
|
|
}
|
|
|
|
// key 섹션 찾기
|
|
if (Array.isArray(keySelector)) {
|
|
this.keySections = keySelector.map(sel => Utils.$(sel)).filter(Boolean);
|
|
} else {
|
|
const section = Utils.$(keySelector);
|
|
if (section) {
|
|
this.keySections = [section];
|
|
}
|
|
}
|
|
|
|
if (this.keySections.length === 0) return;
|
|
|
|
this.setupKeyData();
|
|
this.initScrollTriggers();
|
|
this.initClickHandlers();
|
|
this.setInitialState();
|
|
}
|
|
|
|
/**
|
|
* key 섹션 데이터 설정
|
|
*/
|
|
setupKeyData() {
|
|
this.keyData = this.keySections.map((keyEl) => {
|
|
const titles = keyEl.querySelectorAll(this.config.SELECTORS.titles);
|
|
const sections = keyEl.querySelectorAll(this.config.SELECTORS.sections);
|
|
return { keyEl, titles, sections };
|
|
});
|
|
|
|
// 유효성 검사
|
|
const isValid = this.keyData.every(
|
|
(data) => data.titles.length > 0 && data.sections.length > 0
|
|
);
|
|
if (!isValid) {
|
|
console.warn('[LayoutFixController] Invalid key data structure');
|
|
return;
|
|
}
|
|
|
|
// 마지막 인덱스 계산
|
|
if (this.keyData.length > 0) {
|
|
const lastKeyData = this.keyData[this.keyData.length - 1];
|
|
this.lastIndex = lastKeyData.sections.length - 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스크롤 트리거 초기화
|
|
*/
|
|
initScrollTriggers() {
|
|
this.keyData.forEach(({ titles, sections }, keyIndex) => {
|
|
if (!titles || !sections) return;
|
|
|
|
// 각 섹션: 화면 중앙에 올 때(center center) 해당 li.on
|
|
sections.forEach((section, sectionIndex) => {
|
|
if (!section) return;
|
|
|
|
ScrollTrigger.create({
|
|
trigger: section,
|
|
start: this.config.SCROLL.triggerLine, // 'center center' = 섹션 중앙이 뷰포트 중앙에 올 때
|
|
onEnter: () => this.updateTitle(titles, sectionIndex),
|
|
onEnterBack: () => this.updateTitle(titles, sectionIndex),
|
|
onLeaveBack: () => {
|
|
const prevIndex = sectionIndex > 0 ? sectionIndex - 1 : 0;
|
|
this.updateTitle(titles, prevIndex);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 마지막 섹션: 페이지 하단 도달 시 활성화 (마지막 key 섹션만)
|
|
const isLastKey = keyIndex === this.keyData.length - 1;
|
|
if (isLastKey && sections.length > 0) {
|
|
ScrollTrigger.create({
|
|
trigger: sections[sections.length - 1],
|
|
start: 'bottom bottom',
|
|
onEnter: () => this.updateTitle(titles, titles.length - 1)
|
|
});
|
|
}
|
|
});
|
|
|
|
// 스크롤 리프레시 시 초기 상태 설정
|
|
ScrollTrigger.addEventListener('refresh', () => {
|
|
setTimeout(() => this.setInitialState(), 100);
|
|
});
|
|
ScrollTrigger.refresh();
|
|
|
|
// 스크롤 시: right(.js-fixLeft-secs) 내 섹션이 화면 중앙에 가장 가까울 때 해당 li.on
|
|
const updateActiveByCenter = () => {
|
|
const viewportCenter = window.innerHeight / 2;
|
|
const atBottom = this.isAtBottom();
|
|
this.keyData.forEach(({ titles, sections }, keyIndex) => {
|
|
if (!titles || !sections.length) return;
|
|
let activeIndex = 0;
|
|
const isLastKey = keyIndex === this.keyData.length - 1;
|
|
if (atBottom && isLastKey) {
|
|
activeIndex = titles.length - 1;
|
|
} else {
|
|
let closestDistance = Infinity;
|
|
sections.forEach((section, index) => {
|
|
if (!section) return;
|
|
const rect = section.getBoundingClientRect();
|
|
const sectionCenter = rect.top + rect.height / 2;
|
|
const distance = Math.abs(sectionCenter - viewportCenter);
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
activeIndex = index;
|
|
}
|
|
});
|
|
}
|
|
this.updateTitle(titles, activeIndex);
|
|
});
|
|
};
|
|
|
|
window.addEventListener('scroll', () => {
|
|
updateActiveByCenter();
|
|
}, { passive: true });
|
|
}
|
|
|
|
/**
|
|
* 클릭 핸들러 초기화
|
|
*/
|
|
initClickHandlers() {
|
|
this.keyData.forEach(({ titles, sections }) => {
|
|
if (!titles || !sections) return;
|
|
|
|
titles.forEach((title, index) => {
|
|
title.addEventListener('click', () => {
|
|
this.scrollToSection(sections, titles, index);
|
|
});
|
|
|
|
// 키보드 접근성
|
|
title.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
this.scrollToSection(sections, titles, index);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 타이틀 업데이트
|
|
*/
|
|
updateTitle(titles, activeIndex) {
|
|
if (!titles || !titles.length) return;
|
|
titles.forEach((title, index) => {
|
|
const isActive = index === activeIndex;
|
|
title.classList.toggle('on', isActive);
|
|
title.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
title.setAttribute('tabindex', isActive ? '0' : '-1');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 섹션으로 스크롤
|
|
*/
|
|
scrollToSection(sections, titles, index) {
|
|
const section = sections[index];
|
|
if (!section) return;
|
|
|
|
// 섹션 클래스명 찾기 (analysis: spatial01, statistics01 등 / primary: sec-area-input 등)
|
|
const sectionClass = Array.from(section.classList).find((c) =>
|
|
/^(spatial|statistics|attribute)\d+$/.test(c) || c.startsWith('sec-')
|
|
);
|
|
|
|
if (typeof ScrollToPlugin !== 'undefined' && sectionClass) {
|
|
gsap.to(window, {
|
|
duration: this.config.ANIMATION.duration,
|
|
scrollTo: {
|
|
y: '.' + sectionClass,
|
|
offsetY: this.config.SCROLL.offsetY
|
|
}
|
|
});
|
|
} else {
|
|
section.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
}
|
|
|
|
this.updateTitle(titles, index);
|
|
}
|
|
|
|
/**
|
|
* 초기 상태 설정
|
|
*/
|
|
setInitialState() {
|
|
const atBottom = this.isAtBottom();
|
|
|
|
this.keyData.forEach(({ titles, sections }, keyIndex) => {
|
|
if (!titles || !sections) return;
|
|
|
|
let activeIndex = 0;
|
|
|
|
// 마지막 key 섹션이고 페이지 하단이면 마지막 타이틀 활성화
|
|
const isLastKey = keyIndex === this.keyData.length - 1;
|
|
if (atBottom && isLastKey) {
|
|
activeIndex = titles.length - 1;
|
|
} else {
|
|
const viewportCenter = window.innerHeight / 2;
|
|
let closestIndex = 0;
|
|
let closestDistance = Infinity;
|
|
|
|
// 각 섹션의 중앙점과 뷰포트 중앙의 거리를 계산
|
|
sections.forEach((section, index) => {
|
|
if (!section) return;
|
|
const rect = section.getBoundingClientRect();
|
|
const sectionCenter = rect.top + (rect.height / 2);
|
|
const distance = Math.abs(sectionCenter - viewportCenter);
|
|
|
|
// 섹션이 화면에 보이는 경우에만 고려
|
|
if (rect.top < window.innerHeight && rect.bottom > 0) {
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestIndex = index;
|
|
}
|
|
}
|
|
|
|
// 섹션의 상단이 뷰포트 중앙을 지나갔으면 해당 인덱스로 설정
|
|
if (rect.top <= viewportCenter && rect.bottom > viewportCenter) {
|
|
activeIndex = index;
|
|
}
|
|
});
|
|
|
|
// 가장 가까운 섹션이 있으면 그것을 사용
|
|
if (closestDistance < Infinity) {
|
|
activeIndex = closestIndex;
|
|
}
|
|
}
|
|
|
|
this.updateTitle(titles, activeIndex);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 페이지 하단 여부 확인
|
|
*/
|
|
isAtBottom() {
|
|
return (
|
|
window.scrollY + window.innerHeight >=
|
|
document.documentElement.scrollHeight - this.config.SCROLL.bottomThreshold
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Export
|
|
// ============================================
|
|
window.LayoutFixController = LayoutFixController;
|
|
|
|
})();
|