/** * 진행률 표시 관리 클래스 * 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils) */ class ProgressIndicator { constructor(config, gaugeManager, markerManager = null, dependencies = {}) { // 의존성 주입 (폴백 포함) this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null); this.errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null); this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null); this.utils = dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null); this.animationUtils = dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null); // 이벤트 리스너 ID 저장 (정리용) this.listenerIds = []; try { this.config = config; this.gaugeManager = gaugeManager; this.markerManager = markerManager; // 마커 매니저 참조 추가 this.indicator = null; this.stateIndicator = null; // 평균 상태 표시 요소 this.gaugeSvg = this.gaugeManager.gaugeSvg || this.domUtils?.$("#gauge-svg") || document.getElementById("gauge-svg") || document.getElementById("gauge-svg-mo"); if (!this.gaugeSvg) { this._handleError(new Error('gauge-svg 요소를 찾을 수 없습니다.'), 'constructor'); } this.lastMarkerIndex = -1; // 이전 마커 인덱스 추적 this.lastIndicatorPosition = null; // 이전 indicator 위치 저장 this.animationFrameId = null; // 애니메이션 프레임 ID // 곡선 스타일 설정 (변경 가능) // 'arc-up': 위로 휘어지는 호 (기본) // 'arc-down': 아래로 휘어지는 호 // 'wave': 물결 모양 // 'steep-arc': 가파른 호 // 'gentle-arc': 완만한 호 this.curveStyle = "arc-up"; } catch (error) { this._handleError(error, 'constructor'); } } /** * 에러 처리 헬퍼 * @private */ _handleError(error, context, additionalInfo = {}) { if (this.errorHandler) { this.errorHandler.handle(error, { context: `ProgressIndicator.${context}`, component: 'ProgressIndicator', ...additionalInfo }, false); } else { console.error(`[ProgressIndicator] ${context}:`, error, additionalInfo); } } /** * 곡선 경로 반환 * @private * @returns {string} SVG path 문자열 */ _getCurvePath() { switch (this.curveStyle) { case "arc-up": // 위로 휘어지는 호 (상단 배치) return ''; case "arc-down": // 아래로 휘어지는 호 return ''; case "wave": // 물결 모양 return ''; case "steep-arc": // 가파른 호 return ''; case "gentle-arc": // 완만한 호 return ''; default: return ''; } } /** * 진행률 표시 생성 */ createIndicator() { try { // 컨테이너 생성 this.indicator = this.domUtils?.createElement('div', { class: 'progress-indicator', id: 'progress-indicator' }) || document.createElement("div"); if (!this.domUtils) { this.indicator.className = "progress-indicator"; this.indicator.id = "progress-indicator"; } // 곡선 경로 가져오기 const curvePath = this._getCurvePath(); // SVG 이미지 추가 this.indicator.innerHTML = ` ${curvePath} 진도율 0% `; // 게이지 컨테이너에 추가 const gaugeContainer = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge"); if (gaugeContainer) { gaugeContainer.appendChild(this.indicator); } else { console.warn("[ProgressIndicator] .lessons-gauge 요소를 찾을 수 없습니다."); } // 위치 설정 this._positionIndicator(); // 리사이즈 핸들러 설정 this._setupResizeHandler(); // 평균 상태 표시 생성 this._createStateIndicator(); } catch (error) { this._handleError(error, 'createIndicator'); } } /** * state-indicator 표시 여부 반환 (pc/mo 구분) * @private * @returns {boolean} */ _isStateIndicatorEnabled() { const setting = this.config.settings?.showStateIndicator; if (setting === undefined || setting === null) return true; if (typeof setting === 'boolean') return setting; const isMobile = window.matchMedia('(max-width: 767px)').matches; return isMobile ? setting.mo !== false : setting.pc !== false; } /** * 평균 상태 표시 생성 * @private */ _createStateIndicator() { if (!this._isStateIndicatorEnabled()) return; try { this.stateIndicator = this.domUtils?.createElement('div', { class: 'state-indicator', id: 'state-indicator' }) || document.createElement("div"); if (!this.domUtils) { this.stateIndicator.className = "state-indicator"; this.stateIndicator.id = "state-indicator"; } const gaugeContainer = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge"); if (gaugeContainer) { gaugeContainer.appendChild(this.stateIndicator); } else { console.warn("[ProgressIndicator] .lessons-gauge 요소를 찾을 수 없습니다."); } console.log("[ProgressIndicator] 평균 상태 표시 요소 생성 완료"); } catch (error) { this._handleError(error, '_createStateIndicator'); } } /** * 평균 상태 업데이트 * @private * @param {number} currentProgress - 현재 진행률 (0-100) */ _updateStateIndicator(currentProgress) { if (!this._isStateIndicatorEnabled()) return; if (!this.stateIndicator || !this.config.averageProgress) return; // 모든 챕터가 완료되었는지 확인 if (this.config.chapters && this.config.chapters.length > 0) { const allChaptersCompleted = this.config.chapters.every( (chapter) => chapter.completed === true ); if (allChaptersCompleted) { this.stateIndicator.style.display = "none"; console.log(`[ProgressIndicator] 모든 챕터 완료: 상태 이미지 숨김`); return; } } const threshold = this.config.averageProgress.threshold; let stateImage = ""; let stateType = ""; // 상태 결정 if (currentProgress < threshold - 5) { // 평균 이하 (여유 범위 5% 적용) stateImage = this.config.stateImages.below; stateType = "below"; } else if ( currentProgress >= threshold - 5 && currentProgress <= threshold + 5 ) { // 평균 stateImage = this.config.stateImages.average; stateType = "average"; } else { // 평균 이상 stateImage = this.config.stateImages.above; stateType = "above"; } // SVG 캐시 초기화 if (!this._svgCache) { this._svgCache = {}; } // 이미 로드된 SVG가 있으면 재사용 if (this._svgCache[stateImage]) { this._applySvgContent( this._svgCache[stateImage], stateType, currentProgress ); console.log(`[ProgressIndicator] SVG 캐시 사용: ${stateType}`); return; } // SVG를 인라인으로 로드 fetch(stateImage) .then((response) => { if (!response.ok) throw new Error("SVG 로드 실패"); return response.text(); }) .then((svgContent) => { // 캐시에 저장 this._svgCache[stateImage] = svgContent; this._applySvgContent(svgContent, stateType, currentProgress); }) .catch((error) => { console.error("[ProgressIndicator] SVG 로드 실패:", error); // 폴백: img 태그 사용 this.stateIndicator.innerHTML = ` 학습 상태 `; this._positionStateIndicatorOnFill(currentProgress); }); console.log( `[ProgressIndicator] 평균 상태 업데이트: ${stateType} (현재: ${currentProgress}%, 평균: ${threshold}%)` ); } /** * SVG 컨텐츠 적용 * @private */ _applySvgContent(svgContent, stateType, currentProgress) { if (!this.stateIndicator) return; this.stateIndicator.innerHTML = svgContent; this.stateIndicator.classList.add("state-image", stateType); // SVG DOM이 완전히 로드될 때까지 대기 requestAnimationFrame(() => { // 현재 진행 위치의 마커 위에 배치 this._positionStateIndicatorOnFill(currentProgress); }); } /** * 게이지 fill 끝점에 상태 표시 위치 설정 * @private * @param {number} currentProgress - 현재 진행률 (0-100) */ _positionStateIndicatorOnFill(currentProgress) { if (!this.stateIndicator) return; // 모든 챕터가 완료되었는지 확인 if (this.config.chapters && this.config.chapters.length > 0) { const allChaptersCompleted = this.config.chapters.every( (chapter) => chapter.completed === true ); if (allChaptersCompleted) { this.stateIndicator.style.display = "none"; console.log(`[ProgressIndicator] 모든 챕터 완료: 상태 이미지 숨김`); return; } } const allMarkers = this.config.getAllMarkers(); // 실제 강의만 카운트 (챕터 제외) const learningMarkers = allMarkers.filter( (m) => m.isLearningContent !== false ); const completedCount = learningMarkers.filter((m) => m.completed).length; // 완료된 강의가 0개인 초기 상태: 상태 이미지 숨김 if (completedCount === 0) { this.stateIndicator.style.display = "none"; console.log(`[ProgressIndicator] 학습 완료 0개: 상태 이미지 숨김`); return; } // 모든 강의 완료 상태: 상태 이미지 숨김 if (completedCount >= learningMarkers.length) { this.stateIndicator.style.display = "none"; console.log(`[ProgressIndicator] 전체 학습 완료: 상태 이미지 숨김`); return; } // 다음 학습할 실제 강의 마커 찾기 const targetLearningMarker = learningMarkers[completedCount]; // 다음 학습할 강의 if (!targetLearningMarker) { this.stateIndicator.style.display = "none"; return; } // 전체 마커 배열에서 해당 강의의 인덱스 찾기 const targetMarkerIndex = allMarkers.findIndex( (m) => m.pathPercent === targetLearningMarker.pathPercent && m.label === targetLearningMarker.label ); // 챕터 마커인 경우 상태 이미지 숨김 (안전장치) if (targetLearningMarker.type === "chapter") { this.stateIndicator.style.display = "none"; console.log( `[ProgressIndicator] 챕터 마커(${targetLearningMarker.label})이므로 상태 이미지 숨김` ); return; } // 일반 마커인 경우 표시 this.stateIndicator.style.display = "block"; // 마커 매니저에서 실제 마커 DOM 요소 찾기 const markerElements = document.querySelectorAll(".marker"); if (!markerElements || markerElements.length === 0) { // 마커가 없으면 게이지 라인 기준으로 표시 const currentPercent = currentProgress / 100; this._positionStateIndicator(currentPercent); return; } const targetMarker = markerElements[targetMarkerIndex]; if (!targetMarker) { // 타겟 마커가 없으면 게이지 라인 기준으로 표시 const currentPercent = currentProgress / 100; this._positionStateIndicator(currentPercent); return; } // maskPath 기준으로 끝 지점 계산 (가장 정확함) - PC/모바일 공통 gaugeManager.maskPath 사용 const maskPath = this.gaugeManager.maskPath || document.getElementById("maskPath") || document.getElementById("maskPath-mo"); console.log("타겟 마커 : " + targetMarkerIndex); // ========== 특정 범위로 제한 ========== const allowedRanges = [ [1, 8], // 1부터 8까지 [16, 19], // 16부터 19까지 [24, 25], ]; const isInAllowedRange = allowedRanges.some( ([start, end]) => targetMarkerIndex >= start && targetMarkerIndex <= end ); // maskPath 기준으로 끝 지점 계산 (가장 정확함) if (maskPath && this.gaugeSvg) { // 기존 애니메이션 프레임 취소 if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } const maskPathLength = maskPath.getTotalLength(); // maskPath와 동일한 transition 속도로 자연스럽게 이동 if (!this.stateIndicator.style.transition) { this.stateIndicator.style.transition = "left 0.8s ease-out, top 0.8s ease-out"; } // transition 중 실시간으로 위치 업데이트 const updatePosition = () => { if (!this.stateIndicator || !maskPath) return; // 모든 챕터가 완료되었는지 확인 if (this.config.chapters && this.config.chapters.length > 0) { const allChaptersCompleted = this.config.chapters.every( (chapter) => chapter.completed === true ); if (allChaptersCompleted) { this.stateIndicator.style.display = "none"; if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } return; } } // state-indicator 표시 if (this.stateIndicator.style.display === "none") { this.stateIndicator.style.display = ""; } // 실제 게이지 dashoffset 기준으로 fill 끝점 위치 계산 const fillPathPercent = this._getCurrentFillPathPercent(maskPath, currentProgress); const point = maskPath.getPointAtLength(maskPathLength * fillPathPercent); const viewBox = this.gaugeSvg.viewBox.baseVal; // SVG 좌표를 퍼센트로 변환 const percentX = (point.x / viewBox.width) * 100; const percentY = (point.y / viewBox.height) * 100; // state-indicator를 maskPath의 채워진 끝 지점으로 이동 this.stateIndicator.style.position = "absolute"; this.stateIndicator.style.left = `${percentX}%`; this.stateIndicator.style.top = `${percentY}%`; this.stateIndicator.style.zIndex = "9"; this.stateIndicator.style.pointerEvents = "none"; // transform 설정 (마커 위에 위치하도록) if (isInAllowedRange) { this.stateIndicator.style.transformOrigin = "center center"; this.stateIndicator.style.transform = "translate(-20%, -110%) scaleX(-1)"; } else { this.stateIndicator.style.transformOrigin = ""; this.stateIndicator.style.transform = "translate(-20%, -110%)"; } // SVG 내부 요소 처리 const svg = this.stateIndicator.querySelector("svg"); if (svg) { const emoji = svg.querySelector(".emoji"); const emojiText = svg.querySelector(".emoji-text"); if (isInAllowedRange) { const threshold = this.config.averageProgress.threshold; let emojiTranslateX, emojiTextTranslateX; if (currentProgress < threshold - 5) { emojiTranslateX = "85%"; emojiTextTranslateX = "108%"; } else if ( currentProgress >= threshold - 5 && currentProgress <= threshold + 5 ) { emojiTranslateX = "88%"; emojiTextTranslateX = "108%"; } else { emojiTranslateX = "82%"; emojiTextTranslateX = "108%"; } if (emoji) { emoji.style.transform = `translate(${emojiTranslateX}, 0%) scaleX(-1)`; } if (emojiText) { emojiText.style.transform = `translate(${emojiTextTranslateX}, 0%) scaleX(-1)`; } } else { if (emoji) { emoji.style.transform = ""; } if (emojiText) { emojiText.style.transform = ""; } } } // 위치 저장 this.lastIndicatorPosition = { left: percentX, top: percentY, }; // fillPercent 기반이므로 한 번만 업데이트하고 종료 if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } }; // 위치 업데이트 실행 updatePosition(); } else { // maskPath가 없으면 현재 진행률(폴백) 기반으로 위치 계산 const fillPathPercent = this._getCurrentFillPathPercent(null, currentProgress); const point = this.gaugeManager.getPointAtPercent(fillPathPercent); const viewBox = this.gaugeSvg.viewBox.baseVal; const percentX = (point.x / viewBox.width) * 100; const percentY = (point.y / viewBox.height) * 100; this.stateIndicator.style.position = "absolute"; this.stateIndicator.style.left = `${percentX}%`; this.stateIndicator.style.top = `${percentY}%`; this.stateIndicator.style.zIndex = "9"; this.stateIndicator.style.pointerEvents = "none"; this.lastIndicatorPosition = { left: percentX, top: percentY, }; } // 현재 마커 인덱스 저장 this.lastMarkerIndex = targetMarkerIndex; // 챕터1일 때만 미러링 (emoji/emoji-text 제외) if (isInAllowedRange) { // transform-origin을 중앙으로 설정하여 제자리에서 반전 this.stateIndicator.style.transformOrigin = "center center"; this.stateIndicator.style.transform = "translate(-20%, -110%) scaleX(-1)"; // SVG 내부의 emoji와 emoji-text 요소를 다시 반전 const svg = this.stateIndicator.querySelector("svg"); if (svg) { const emoji = svg.querySelector(".emoji"); const emojiText = svg.querySelector(".emoji-text"); // 상태에 따라 다른 translate 값 적용 const threshold = this.config.averageProgress.threshold; let emojiTranslateX, emojiTextTranslateX; if (currentProgress < threshold - 5) { // 평균 이하 (below) emojiTranslateX = "85%"; emojiTextTranslateX = "108%"; } else if ( currentProgress >= threshold - 5 && currentProgress <= threshold + 5 ) { // 평균 (average) emojiTranslateX = "88%"; emojiTextTranslateX = "108%"; } else { // 평균 이상 (above) emojiTranslateX = "82%"; emojiTextTranslateX = "108%"; } if (emoji) { emoji.style.transform = `translate(${emojiTranslateX}, 0%) scaleX(-1)`; } if (emojiText) { emojiText.style.transform = `translate(${emojiTextTranslateX}, 0%) scaleX(-1)`; } } } else { // 챕터2 이상일 때는 기본 상태 this.stateIndicator.style.transformOrigin = ""; this.stateIndicator.style.transform = "translate(-20%, -110%)"; const svg = this.stateIndicator.querySelector("svg"); if (svg) { const emoji = svg.querySelector(".emoji"); const emojiText = svg.querySelector(".emoji-text"); if (emoji) { emoji.style.transform = ""; } if (emojiText) { emojiText.style.transform = ""; } } } } /** * 실제 게이지 채움 비율(pathPercent) 반환 * - 우선순위: maskPath의 strokeDashoffset -> currentProgress 폴백 * @private * @param {SVGPathElement|null} maskPath * @param {number} currentProgress - 현재 진행률(0-100), 폴백용 * @returns {number} pathPercent (0-1) */ _getCurrentFillPathPercent(maskPath, currentProgress = 0) { try { const fallbackPercent = Math.max(0, Math.min(1, (Number(currentProgress) || 0) / 100)); const path = maskPath || this.gaugeManager?.maskPath; if (!path) return fallbackPercent; const totalLength = this.gaugeManager?.pathLength && this.gaugeManager.pathLength > 0 ? this.gaugeManager.pathLength : path.getTotalLength(); if (!totalLength || totalLength <= 0) return fallbackPercent; const inlineDashOffset = parseFloat(path.style.strokeDashoffset); const computedDashOffset = parseFloat(window.getComputedStyle(path).strokeDashoffset); const dashOffset = Number.isFinite(inlineDashOffset) ? inlineDashOffset : (Number.isFinite(computedDashOffset) ? computedDashOffset : NaN); if (!Number.isFinite(dashOffset)) return fallbackPercent; return Math.max(0, Math.min(1, 1 - (dashOffset / totalLength))); } catch (error) { this._handleError(error, '_getCurrentFillPathPercent', { currentProgress }); return Math.max(0, Math.min(1, (Number(currentProgress) || 0) / 100)); } } /** * 경로를 따라 애니메이션으로 이동 * @private * @param {SVGPathElement} pathFill - path-fill (PC) 또는 path-fill-mo (모바일) 경로 요소 * @param {number} startProgress - 시작 진행률 (0-1) * @param {number} endProgress - 끝 진행률 (0-1) * @param {number} targetMarkerLeft - 목표 마커의 left 위치 (%) * @param {number} targetMarkerTop - 목표 마커의 top 위치 (%) * @param {boolean} isInAllowedRange - 특정 범위 여부 * @param {number} currentProgress - 현재 진행률 (0-100) */ _animateAlongPath( pathFill, startProgress, endProgress, targetMarkerLeft, targetMarkerTop, isInAllowedRange, currentProgress ) { if (!this.stateIndicator || !this.gaugeSvg) return; const pathLength = pathFill.getTotalLength(); const viewBox = this.gaugeSvg.viewBox.baseVal; const duration = 800; // 애니메이션 지속 시간 (ms) const startTime = performance.now(); const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // easing 함수 (ease-out) const easedProgress = 1 - Math.pow(1 - progress, 3); // 현재 경로상의 위치 계산 const currentPathProgress = startProgress + (endProgress - startProgress) * easedProgress; const point = pathFill.getPointAtLength(pathLength * currentPathProgress); const pathPercentX = (point.x / viewBox.width) * 100; const pathPercentY = (point.y / viewBox.height) * 100; // 경로상 위치와 마커 위치를 보간 (마지막에는 마커 위치로 수렴) const finalProgress = Math.min(progress * 1.2, 1); // 1.2배로 해서 마커 위치로 더 빨리 수렴 const percentX = pathPercentX + (targetMarkerLeft - pathPercentX) * finalProgress; const percentY = pathPercentY + (targetMarkerTop - pathPercentY) * finalProgress; // 위치 업데이트 this.stateIndicator.style.left = `${percentX}%`; this.stateIndicator.style.top = `${percentY}%`; // transform 설정 if (isInAllowedRange) { this.stateIndicator.style.transformOrigin = "center center"; this.stateIndicator.style.transform = "translate(-20%, -110%) scaleX(-1)"; } else { this.stateIndicator.style.transformOrigin = ""; this.stateIndicator.style.transform = "translate(-20%, -110%)"; } // SVG 내부 요소 처리 const svg = this.stateIndicator.querySelector("svg"); if (svg) { const emoji = svg.querySelector(".emoji"); const emojiText = svg.querySelector(".emoji-text"); if (isInAllowedRange) { const threshold = this.config.averageProgress.threshold; let emojiTranslateX, emojiTextTranslateX; if (currentProgress < threshold - 5) { emojiTranslateX = "85%"; emojiTextTranslateX = "108%"; } else if ( currentProgress >= threshold - 5 && currentProgress <= threshold + 5 ) { emojiTranslateX = "88%"; emojiTextTranslateX = "108%"; } else { emojiTranslateX = "82%"; emojiTextTranslateX = "108%"; } if (emoji) { emoji.style.transform = `translate(${emojiTranslateX}, 0%) scaleX(-1)`; } if (emojiText) { emojiText.style.transform = `translate(${emojiTextTranslateX}, 0%) scaleX(-1)`; } } else { if (emoji) { emoji.style.transform = ""; } if (emojiText) { emojiText.style.transform = ""; } } } if (progress < 1) { requestAnimationFrame(animate); } else { // 애니메이션 완료 후 정확히 마커 위치로 설정 this.stateIndicator.style.left = `${targetMarkerLeft}%`; this.stateIndicator.style.top = `${targetMarkerTop}%`; } }; requestAnimationFrame(animate); } /** * 상태 표시 위치 설정 * @private * @param {number} percent - 위치 퍼센트 (0-1) */ _positionStateIndicator(percent) { if (!this.stateIndicator || !this.gaugeSvg) return; const viewBox = this.gaugeSvg.viewBox.baseVal; const point = this.gaugeManager.getPointAtPercent(percent); // 퍼센트 기반 위치 계산 const percentX = (point.x / viewBox.width) * 100; const percentY = (point.y / viewBox.height) * 100; // 위치 설정 (게이지 라인 위쪽에 배치) this.stateIndicator.style.position = "absolute"; this.stateIndicator.style.left = `${percentX}%`; this.stateIndicator.style.top = `${percentY}%`; this.stateIndicator.style.transform = "translate(-50%, -150%)"; // 라인 위쪽으로 배치 this.stateIndicator.style.zIndex = "12"; this.stateIndicator.style.pointerEvents = "none"; console.log( `[ProgressIndicator] 상태 표시 위치: (${percentX.toFixed(2)}%, ${percentY.toFixed(2)}%)` ); } /** * 트로피 위치 설정 * @private */ _positionIndicator() { if (!this.gaugeSvg || !this.indicator) return; const viewBox = this.gaugeSvg.viewBox.baseVal; // 경로의 끝 지점 (100% 위치) const endPoint = this.gaugeManager.getPointAtPercent(1.0); // 완료 상태 확인 const isCompleted = this.indicator.classList.contains("completed"); // 퍼센트 기반 위치 계산 let percentX, percentY, transform; if (isCompleted) { // 완료 상태: 다른 위치와 변형 percentX = (endPoint.x / viewBox.width) * 100 + 26; percentY = (endPoint.y / viewBox.height) * 100; transform = "translate(-50%, -100%)"; // 완료 트로피는 덜 올림 } else { // 진행 중 상태: 기존 위치 percentX = (endPoint.x / viewBox.width) * 100 + 26; percentY = (endPoint.y / viewBox.height) * 100 + 5; transform = "translate(-50%, -120%)"; // 위로 120% 이동 } // 위치 설정 (경로 끝점 위쪽에 배치) this.indicator.style.position = "absolute"; this.indicator.style.left = `${percentX}%`; this.indicator.style.top = `${percentY}%`; this.indicator.style.transform = transform; this.indicator.style.zIndex = "15"; this.indicator.style.pointerEvents = "none"; // 클릭 이벤트 통과 console.log( `[ProgressIndicator] 위치 설정 (${isCompleted ? "완료" : "진행중"}): (${percentX.toFixed(2)}%, ${percentY.toFixed(2)}%)` ); } /** * 리사이즈 핸들러 설정 * @private */ _setupResizeHandler() { try { const resizeHandler = () => { try { // gaugeManager의 최신 gaugeSvg 참조 업데이트 (PC↔모바일 전환 시) this.gaugeSvg = this.gaugeManager?.gaugeSvg || this.gaugeSvg; console.log("[ProgressIndicator] 리사이즈 감지: 위치 재계산"); this._positionIndicator(); // 상태 표시도 재계산 (현재 진행률 기준, 마커 위) if (this.stateIndicator && this.config && this.config.averageProgress) { const valueSpan = this.domUtils?.$(".progress-value", this.indicator) || this.indicator?.querySelector(".progress-value"); if (valueSpan) { const currentProgress = parseInt(valueSpan.textContent) || 0; this._positionStateIndicatorOnFill(currentProgress); } } } catch (error) { this._handleError(error, '_setupResizeHandler.resizeHandler'); } }; // Utils.throttle 사용 (있는 경우) 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 { // 폴백: debounce 구현 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, '_setupResizeHandler'); } } /** * 진행률 업데이트 * @param {Array} allMarkers - 전체 마커 배열 */ updateProgress(allMarkers) { try { if (!this.indicator) { console.warn("[ProgressIndicator] indicator가 없습니다."); return; } if (!allMarkers || !Array.isArray(allMarkers)) { this._handleError(new Error('allMarkers가 배열이 아닙니다.'), 'updateProgress'); return; } // 실제 강의만 카운트 (챕터 제외) const learningContent = allMarkers.filter( (m) => m && m.isLearningContent !== false ); const completedCount = learningContent.filter((m) => m && m.completed === true).length; const totalCount = learningContent.length; const percent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; // tspan 요소 찾기 const valueSpan = this.domUtils?.$(".progress-value", this.indicator) || this.indicator.querySelector(".progress-value"); if (valueSpan) { valueSpan.textContent = `${percent}%`; } // 평균 상태 업데이트 if (this.config && this.config.averageProgress) { this._updateStateIndicator(percent); } // 100% 완료 시 트로피 이미지 교체 if (percent === 100 && !this.indicator.classList.contains("completed")) { this._replaceWithCompletedTrophy(); } console.log( `[ProgressIndicator] 진행률 업데이트: ${percent}% (${completedCount}/${totalCount} 강의)` ); } catch (error) { this._handleError(error, 'updateProgress', { allMarkers }); } } /** * 완료된 트로피 이미지로 교체 * @private */ async _replaceWithCompletedTrophy() { try { if (!this.indicator) { console.warn("[ProgressIndicator] indicator가 없습니다."); return; } // 외부 SVG 파일 경로 const svgPath = "/img/learning/img_trophy_completed.svg"; // SVG 파일 로드 const response = await fetch(svgPath); if (!response.ok) { throw new Error(`SVG 로드 실패: ${response.status}`); } const svgText = await response.text(); // 기존 indicator의 내용을 완전히 교체 this.indicator.innerHTML = svgText; // completed 클래스 추가 if (this.domUtils) { this.domUtils.addClasses(this.indicator, 'completed'); } else { this.indicator.classList.add("completed"); } // 위치 재계산 this._positionIndicator(); console.log("[ProgressIndicator] 완료 트로피로 교체 완료"); } catch (error) { this._handleError(error, '_replaceWithCompletedTrophy'); } } /** * 리소스 정리 (이벤트 리스너 제거) */ destroy() { try { // 이벤트 리스너 제거 if (this.eventManager && this.listenerIds.length > 0) { this.listenerIds.forEach(({ element, id }) => { this.eventManager.off(element, id); }); this.listenerIds = []; } // 애니메이션 프레임 취소 if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } // 참조 정리 this.indicator = null; this.stateIndicator = null; this.config = null; this.gaugeManager = null; this.markerManager = null; this.gaugeSvg = null; this.lastMarkerIndex = -1; this.lastIndicatorPosition = null; } catch (error) { this._handleError(error, 'destroy'); } } }