/** * 챕터 카드 관리 클래스 (CSS 기반 디자인) * 개선된 공통 모듈 활용 (EventManager, ErrorHandler, DOMUtils) */ class ChapterCardManager { constructor(config, gaugeManager, dependencies = {}) { this.config = config; this.gaugeManager = gaugeManager; this.chapterCards = []; this.cardsContainer = null; this.modalInstance = null; // 의존성 주입 (폴백 포함) this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null); this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null); this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null); this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null); this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null); // 이벤트 리스너 ID 저장 (정리용) this.listenerIds = []; // CSS 스타일 주입 this._injectStyles(); } /** * CSS 스타일 주입 * @private */ _injectStyles() { try { if (document.getElementById("chapter-card-styles")) return; const style = this.domUtils?.createElement('style', { id: 'chapter-card-styles' }) || document.createElement("style"); style.id = "chapter-card-styles"; style.textContent = ` .chapter-card:hover .card-play-button { transform: scale(1.1); } /* 호버 효과 */ .chapter-card:hover .chapter-card-inner { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15); } .chapter-card.current:hover .chapter-card-inner { box-shadow: 0 12px 32px rgba(31, 155, 118, 0.3); } .chapter-card.completed:hover .chapter-card-inner { box-shadow: 0 12px 32px rgba(171, 61, 0, 0.25); } `; document.head.appendChild(style); } catch (error) { this._handleError(error, 'ChapterCardManager._injectStyles'); } } /** * 모달 인스턴스 설정 * @param {VideoModal} modal - 모달 인스턴스 */ setModalInstance(modal) { this.modalInstance = modal; } /** * 챕터 카드 생성 */ createChapterCards() { try { const gaugeElement = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge"); if (!gaugeElement) { console.warn('[ChapterCardManager] .lessons-gauge 요소를 찾을 수 없습니다.'); return; } this.cardsContainer = this.domUtils?.$(".chapter-list", gaugeElement) || gaugeElement.querySelector(".chapter-list"); if (!this.cardsContainer) { this.cardsContainer = this.domUtils?.createElement('ul', { class: 'chapter-list' }) || document.createElement("ul"); this.cardsContainer.className = "chapter-list"; gaugeElement.appendChild(this.cardsContainer); } this.domUtils?.empty(this.cardsContainer) || (this.cardsContainer.innerHTML = ""); this.chapterCards = []; this.config.chapters.forEach((chapter, chapterIndex) => { // 새 구조: 챕터 자체가 마커이므로 chapter에서 직접 정보 가져오기 if (chapter.type === "chapter") { this._createCard(chapter, chapterIndex, chapter); } }); this._setupResizeHandler(); console.log( `[ChapterCardManager] ${this.chapterCards.length}개의 챕터 카드 생성 완료` ); } catch (error) { this._handleError(error, 'ChapterCardManager.createChapterCards'); } } /** * 리사이즈 핸들러 설정 (PC/모바일 전환 시 카드 위치 재계산) * @private */ _setupResizeHandler() { try { const resizeHandler = () => { try { if (this.gaugeManager && typeof this.gaugeManager.updateMobileState === 'function') { this.gaugeManager.updateMobileState(); } this._repositionCards(); } catch (error) { this._handleError(error, 'ChapterCardManager._setupResizeHandler.resizeHandler'); } }; if (this.utils && this.utils.throttle) { const throttledResize = this.utils.throttle(resizeHandler, 100); if (this.eventManager) { const listenerId = this.eventManager.on(window, "resize", throttledResize); this.listenerIds.push({ element: window, id: listenerId, type: 'resize' }); } else { window.addEventListener("resize", throttledResize); } } else { let resizeTimer; const debouncedResize = () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(resizeHandler, 100); }; if (this.eventManager) { const listenerId = this.eventManager.on(window, "resize", debouncedResize); this.listenerIds.push({ element: window, id: listenerId, type: 'resize' }); } else { window.addEventListener("resize", debouncedResize); } } } catch (error) { this._handleError(error, 'ChapterCardManager._setupResizeHandler'); } } /** * 카드 위치 재설정 (리사이즈 시) * @private */ _repositionCards() { try { this.chapterCards.forEach((card) => { if (card && card.element && card.chapterLesson) { this._positionCard(card.element, card.chapterLesson); } }); } catch (error) { this._handleError(error, 'ChapterCardManager._repositionCards'); } } /** * 개별 챕터 카드 생성 * @private */ _createCard(chapter, chapterIndex, chapterLesson) { try { const li = this.domUtils?.createElement('li', { class: 'chapter-card' }) || document.createElement("li"); li.classList.add("chapter-card"); const state = this._getChapterState(chapter); if (state) { this.domUtils?.addClasses(li, state) || li.classList.add(state); } li.style.cursor = "pointer"; // 이벤트 리스너 등록 (EventManager 사용) const clickHandler = () => { this._handleCardClick(chapter, chapterIndex); }; if (this.eventManager) { const listenerId = this.eventManager.on(li, "click", clickHandler); this.listenerIds.push({ element: li, id: listenerId }); } else { li.addEventListener("click", clickHandler); } const cardContent = this._createCardContent(chapter, state, chapterIndex); li.appendChild(cardContent); this._positionCard(li, chapterLesson); this.cardsContainer.appendChild(li); this.chapterCards.push({ element: li, chapter: chapter, state: state, chapterIndex: chapterIndex, chapterLesson: chapterLesson, }); } catch (error) { this._handleError(error, 'ChapterCardManager._createCard', { chapter, chapterIndex }); } } /** * 카드 콘텐츠 생성 * @private */ _createCardContent(chapter, state, chapterIndex) { try { const inner = this.domUtils?.createElement('div', { class: 'chapter-card-inner' }) || document.createElement("div"); inner.className = "chapter-card-inner"; // 썸네일 영역 const thumbnailContainer = this.domUtils?.createElement('div', { class: 'card-thumbnail-container' }) || document.createElement("div"); thumbnailContainer.className = "card-thumbnail-container"; const thumbnail = this.domUtils?.createElement('img', { class: 'card-thumbnail', src: `/img/learning/img_learning_0${(chapterIndex % 6) + 1}.jpg`, alt: chapter.name, loading: 'lazy' }) || document.createElement("img"); if (!this.domUtils) { thumbnail.className = "card-thumbnail"; thumbnail.src = `/img/learning/img_learning_0${(chapterIndex % 6) + 1}.jpg`; thumbnail.alt = chapter.name; thumbnail.loading = "lazy"; } thumbnailContainer.appendChild(thumbnail); // 플레이 버튼 const playButton = this.domUtils?.createElement('img', { class: 'card-play-button', src: this._getPlayButtonImagePath(state), alt: '재생', loading: 'lazy' }) || document.createElement("img"); if (!this.domUtils) { playButton.className = "card-play-button"; playButton.src = this._getPlayButtonImagePath(state); playButton.alt = "재생"; playButton.loading = "lazy"; } thumbnailContainer.appendChild(playButton); // 게이지바 추가 const gaugeBar = this.domUtils?.createElement('div', { class: 'card-gauge-bar' }) || document.createElement("div"); gaugeBar.className = "card-gauge-bar"; const gaugeFill = this.domUtils?.createElement('div', { class: 'card-gauge-fill' }) || document.createElement("div"); gaugeFill.className = "card-gauge-fill"; const progressPercent = this._calculateChapterProgress(chapter); gaugeFill.style.width = progressPercent + "%"; gaugeBar.appendChild(gaugeFill); thumbnailContainer.appendChild(gaugeBar); inner.appendChild(thumbnailContainer); // 제목 const title = this.domUtils?.createElement('div', { class: 'card-title' }, chapter.name) || document.createElement("div"); if (!this.domUtils) { title.className = "card-title"; title.textContent = chapter.name; } inner.appendChild(title); // 스탬프 const stamp = this.domUtils?.createElement('div', { class: 'card-stamp' }) || document.createElement("div"); stamp.className = "card-stamp"; inner.appendChild(stamp); // 그림자 const shadow = this.domUtils?.createElement('div', { class: 'shadow-effect' }) || document.createElement("div"); shadow.className = "shadow-effect"; inner.appendChild(shadow); return inner; } catch (error) { this._handleError(error, 'ChapterCardManager._createCardContent', { chapter, state, chapterIndex }); // 에러 발생 시 최소한의 요소라도 반환 const fallback = document.createElement("div"); fallback.className = "chapter-card-inner"; fallback.textContent = chapter.name || "Chapter"; return fallback; } } /** * 카드 클릭 핸들러 * @private */ _handleCardClick(chapter, chapterIndex) { try { if (!this.modalInstance) { console.warn('[ChapterCardManager] 모달 인스턴스가 설정되지 않았습니다.'); return; } console.log( `[ChapterCardManager] 챕터 카드 클릭: ${chapter.name} (챕터 ${chapterIndex + 1})` ); // 챕터는 시작점 표시용이므로 항상 첫 번째 미완료 lesson부터 시작 let targetLessonIndex = 0; // 첫 번째 lesson // 첫 번째 미완료 lesson 찾기 for (let i = 0; i < chapter.lessons.length; i++) { if (!chapter.lessons[i].completed) { targetLessonIndex = i; break; } } // 모든 lesson이 완료된 경우 첫 번째 lesson으로 if (targetLessonIndex >= chapter.lessons.length) { targetLessonIndex = 0; } const globalIndex = this.config.toGlobalIndex( chapterIndex, targetLessonIndex ); const targetLesson = chapter.lessons[targetLessonIndex]; if (!targetLesson) { console.error('[ChapterCardManager] 대상 lesson을 찾을 수 없습니다.'); return; } const targetLabel = targetLesson.label; console.log( `[ChapterCardManager] 대상 학습: ${targetLabel} (글로벌 인덱스: ${globalIndex})` ); this.modalInstance.loadChapter(chapter, chapterIndex, globalIndex); } catch (error) { this._handleError(error, 'ChapterCardManager._handleCardClick', { chapter, chapterIndex }); } } /** * 챕터 상태 결정 * @private */ _getChapterState(chapter) { // 새 구조: chapter.completed 사용 (자동 업데이트됨) if (chapter.completed) return "completed"; const anyStarted = chapter.lessons.some((lesson) => this._isLessonStarted(lesson)); if (anyStarted) return "current"; // 챕터 자체가 활성화되어 있는지 확인 const isActive = this._isChapterActive(chapter); if (isActive) return "current"; return "base"; } /** * 학습 시작 여부 확인 (완료 또는 1초 이상 시청) * @private */ _isLessonStarted(lesson) { if (!lesson) return false; if (lesson.completed === true) return true; const watchTm = Number.parseInt(lesson.watch_tm ?? 0, 10) || 0; return watchTm >= 1; } /** * 챕터 활성화 여부 확인 * @private */ _isChapterActive(chapter) { const allMarkers = this.config.getAllMarkers(); const chapterMarkerIndex = allMarkers.findIndex( (m) => m.pathPercent === chapter.pathPercent && m.isChapterMarker === true ); if (chapterMarkerIndex === -1) return false; if (chapterMarkerIndex === 0) return true; return allMarkers[chapterMarkerIndex - 1].completed; } /** * 플레이 버튼 이미지 경로 반환 * @private */ _getPlayButtonImagePath(state) { switch (state) { case "completed": return "/img/learning/btn_play_completed.png"; case "current": return "/img/learning/btn_play_current.png"; default: return "/img/learning/btn_play_base.png"; } } /** * 챕터 진행률 계산 * @private * @param {Object} chapter - 챕터 객체 * @returns {number} 진행률 (0-100) */ _calculateChapterProgress(chapter) { const completedCount = chapter.lessons.filter( (lesson) => lesson.completed ).length; const totalCount = chapter.lessons.length; const progressPercent = Math.round((completedCount / totalCount) * 100); console.log( `[ChapterCardManager] 챕터 "${chapter.name}" 진행률: ${completedCount}/${totalCount} (${progressPercent}%)` ); return progressPercent; } /** * 카드 위치 설정 * @private */ _positionCard(li, chapterLesson) { try { const gaugeSvg = this.gaugeManager.gaugeSvg || document.getElementById("gauge-svg") || document.getElementById("gauge-svg-mo"); if (!gaugeSvg) { console.warn('[ChapterCardManager] gauge-svg 요소를 찾을 수 없습니다.'); return; } const viewBox = gaugeSvg.viewBox.baseVal; if (!viewBox || !viewBox.width || !viewBox.height) { console.warn('[ChapterCardManager] SVG viewBox가 유효하지 않습니다.'); return; } const isMobile = this.gaugeManager.isMobile; const pathPercent = (isMobile && chapterLesson.pathPercentMo != null) ? chapterLesson.pathPercentMo : (chapterLesson.pathPercent || 0); const point = this.gaugeManager.getPointAtPercent(pathPercent); if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') { console.warn('[ChapterCardManager] 유효하지 않은 포인트입니다.'); return; } // hanmac_study 기준 보정값 적용 const percentX = (point.x / viewBox.width) * 100 + 1.3; const percentY = (point.y / viewBox.height) * 100 - 3; if (this.domUtils) { this.domUtils.setStyles(li, { position: "absolute", left: `${percentX}%`, top: `${percentY}%`, transform: "translate(-50%, -105%)", zIndex: "10" }); } else { li.style.position = "absolute"; li.style.left = `${percentX}%`; li.style.top = `${percentY}%`; li.style.transform = "translate(-50%, -105%)"; li.style.zIndex = "10"; } console.log( `[ChapterCardManager] 카드 위치: (${percentX.toFixed(2)}%, ${percentY.toFixed(2)}%)` ); } catch (error) { this._handleError(error, 'ChapterCardManager._positionCard', { chapterLesson }); } } /** * 챕터 카드 상태 업데이트 * @param {boolean} forceUpdate - 강제 업데이트 여부 */ updateChapterCards(forceUpdate = false) { try { this.chapterCards.forEach((card, index) => { try { const chapter = card.chapter; const newState = this._getChapterState(chapter); const newProgress = this._calculateChapterProgress(chapter); const gaugeFill = this.domUtils?.$(".card-gauge-fill", card.element) || card.element.querySelector(".card-gauge-fill"); const currentProgress = gaugeFill ? parseInt(gaugeFill.style.width) || 0 : 0; const shouldUpdate = forceUpdate || card.state !== newState || currentProgress !== newProgress; if (shouldUpdate) { console.log( `[ChapterCardManager] 챕터 ${index + 1} ${forceUpdate ? "강제 " : ""}업데이트` ); // 클래스 업데이트 card.element.className = "chapter-card"; if (newState) { if (this.domUtils) { this.domUtils.addClasses(card.element, newState); } else { card.element.classList.add(newState); } } // 플레이 버튼 업데이트 const playButton = this.domUtils?.$(".card-play-button", card.element) || card.element.querySelector(".card-play-button"); if (playButton) { playButton.src = this._getPlayButtonImagePath(newState); } // 게이지바 업데이트 (애니메이션 적용 가능) if (gaugeFill) { if (this.animationUtils) { this.animationUtils.progressBar(gaugeFill, newProgress, 300); } else { gaugeFill.style.width = newProgress + "%"; } } card.state = newState; } } catch (error) { this._handleError(error, 'ChapterCardManager.updateChapterCards.card', { index }); } }); } catch (error) { this._handleError(error, 'ChapterCardManager.updateChapterCards'); } } /** * 에러 처리 헬퍼 메서드 * @private */ _handleError(error, context, additionalInfo = {}) { if (this.errorHandler) { this.errorHandler.handle(error, { context, ...additionalInfo, component: 'ChapterCardManager' }, false); } else { console.error(`[ChapterCardManager] ${context}:`, error, additionalInfo); } } /** * 리소스 정리 (이벤트 리스너 제거) */ destroy() { try { // 이벤트 리스너 제거 if (this.eventManager) { this.listenerIds.forEach(({ element, id }) => { this.eventManager.off(element, id); }); this.listenerIds = []; } // 카드 배열 초기화 this.chapterCards = []; this.cardsContainer = null; this.modalInstance = null; } catch (error) { this._handleError(error, 'ChapterCardManager.destroy'); } } }