20260205 업데이트(컨텐츠 페이지 연결)
This commit is contained in:
316
kngil/js/layout-fix.js
Normal file
316
kngil/js/layout-fix.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user