/** * 게이지 진행률 관리 클래스 * 공통 모듈 활용 (ErrorHandler, DOMUtils, AnimationUtils, Utils) */ class GaugeManager { constructor(dependencies = {}) { // 의존성 주입 (폴백 포함) this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : 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); try { // PC/모바일 구분: 768px 미만이면 모바일 게이지 SVG 사용 this.isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; const gaugeSvgId = this.isMobile ? 'gauge-svg-mo' : 'gauge-svg'; const maskPathId = this.isMobile ? 'maskPath-mo' : 'maskPath'; this.maskPath = this.domUtils?.$("#" + maskPathId) || document.getElementById(maskPathId); this.gaugeSvg = this.domUtils?.$("#" + gaugeSvgId) || document.getElementById(gaugeSvgId); this.pathLength = 0; if (!this.maskPath) { this._handleError(new Error('maskPath 요소를 찾을 수 없습니다.'), 'GaugeManager.constructor'); } if (!this.gaugeSvg) { this._handleError(new Error('gauge-svg 요소를 찾을 수 없습니다.'), 'GaugeManager.constructor'); } } catch (error) { this._handleError(error, 'GaugeManager.constructor'); } } /** * 에러 처리 헬퍼 * @private */ _handleError(error, context, additionalInfo = {}) { if (this.errorHandler) { this.errorHandler.handle(error, { context: `GaugeManager.${context}`, component: 'GaugeManager', ...additionalInfo }, false); } else { console.error(`[GaugeManager] ${context}:`, error, additionalInfo); } } /** * 진행률 설정 * @param {number} percent - 진행률 (0-100) 또는 pathPercent (0-1) * @param {boolean} isPathPercent - percent가 pathPercent인지 여부 (기본값: false) * @param {boolean} animate - 애니메이션 적용 여부 (기본값: true) */ setProgress(percent, isPathPercent = false, animate = true) { try { if (!this.maskPath) { this._handleError(new Error('maskPath 요소를 찾을 수 없습니다.'), 'setProgress'); return; } // 입력값 유효성 검증 if (typeof percent !== 'number' || isNaN(percent)) { this._handleError(new Error(`유효하지 않은 percent 값: ${percent}`), 'setProgress'); return; } if (this.pathLength === 0) { this.pathLength = this.maskPath.getTotalLength(); if (this.pathLength === 0) { this._handleError(new Error('pathLength가 0입니다.'), 'setProgress'); return; } } let targetPathPercent; if (isPathPercent) { // pathPercent를 직접 사용 (0-1) targetPathPercent = Math.max(0, Math.min(1, percent)); } else { // percent를 pathPercent로 변환 (0-100 -> 0-1) targetPathPercent = Math.max(0, Math.min(1, percent / 100)); } // maskPath: path 0%=시작부터 채움. 시작점(하단)→끝점(상단) 방향이 PC/MO 동일하므로 같은 offset 공식 사용 const targetLength = this.pathLength * targetPathPercent; const targetOffset = this.pathLength - targetLength; // 애니메이션 처리 if (animate) { // AnimationUtils 활용 (있는 경우) if (this.animationUtils) { // progressBar 애니메이션을 SVG path에 적용하기 어려우므로 기존 방식 유지 // 하지만 transition은 DOMUtils로 관리 가능 if (this.domUtils) { this.domUtils.setStyles(this.maskPath, { transition: "stroke-dashoffset 0.8s ease-out" }); } else { if (!this.maskPath.style.transition) { this.maskPath.style.transition = "stroke-dashoffset 0.8s ease-out"; } } } else { // 애니메이션을 위한 transition 추가 if (!this.maskPath.style.transition) { this.maskPath.style.transition = "stroke-dashoffset 0.8s ease-out"; } } } else { // 초기 로딩 시 애니메이션 없이 즉시 적용 // transition을 먼저 none으로 설정하여 이전 애니메이션 방지 if (this.domUtils) { this.domUtils.setStyles(this.maskPath, { transition: "none" }); } else { this.maskPath.style.transition = "none"; } // 강제로 레이아웃 계산하여 transition 변경사항 즉시 적용 void this.maskPath.offsetHeight; } // stroke-dasharray를 인라인 스타일로 설정 (단일 값 → dash=pathLength, gap=pathLength) // SVG 속성(setAttribute) 대신 인라인 스타일 사용: PC/MO 모두 동일하게 dashoffset으로 제어 가능 if (this.domUtils) { this.domUtils.setStyles(this.maskPath, { strokeDasharray: `${this.pathLength}`, strokeDashoffset: targetOffset }); } else { this.maskPath.style.strokeDasharray = `${this.pathLength}`; this.maskPath.style.strokeDashoffset = targetOffset; } // 애니메이션 비활성화 후 다음 업데이트를 위해 transition 복원 if (!animate) { // 강제로 레이아웃 계산하여 값 변경사항 즉시 적용 void this.maskPath.offsetHeight; // 다음 프레임에서 transition 복원 (현재 변경사항 적용 후) requestAnimationFrame(() => { if (this.domUtils) { this.domUtils.setStyles(this.maskPath, { transition: "stroke-dashoffset 0.8s ease-out" }); } else { this.maskPath.style.transition = "stroke-dashoffset 0.8s ease-out"; } }); } console.log( `[GaugeManager] setProgress: pathPercent=${targetPathPercent.toFixed(4)}, targetOffset=${targetOffset.toFixed(2)}, pathLength=${this.pathLength.toFixed(2)}, animate=${animate}` ); } catch (error) { this._handleError(error, 'setProgress', { percent, isPathPercent, animate }); } } /** * 경로상의 특정 위치 좌표 반환 * @param {number} percent - 위치 (0-1) * @returns {DOMPoint|null} 좌표 */ getPointAtPercent(percent) { try { if (!this.maskPath) { this._handleError(new Error('maskPath 요소를 찾을 수 없습니다.'), 'getPointAtPercent'); return null; } // 입력값 유효성 검증 if (typeof percent !== 'number' || isNaN(percent)) { this._handleError(new Error(`유효하지 않은 percent 값: ${percent}`), 'getPointAtPercent'); return null; } // percent를 0-1 범위로 제한 const clampedPercent = Math.max(0, Math.min(1, percent)); if (this.pathLength === 0) { this.pathLength = this.maskPath.getTotalLength(); if (this.pathLength === 0) { this._handleError(new Error('pathLength가 0입니다.'), 'getPointAtPercent'); return null; } } // PC/MO 모두 path가 START(0%)→트로피(100%) 방향. 동일 공식 사용 const lengthPercent = clampedPercent; return this.maskPath.getPointAtLength(this.pathLength * lengthPercent); } catch (error) { this._handleError(error, 'getPointAtPercent', { percent }); return null; } } /** * 마커의 실제 DOM 위치에 가장 가까운 maskPath 지점 찾기 * @param {number} markerPercentX - 마커의 X 위치 (퍼센트) * @param {number} markerPercentY - 마커의 Y 위치 (퍼센트) * @returns {number} 가장 가까운 지점의 pathPercent (0-1) */ findClosestPathPercent(markerPercentX, markerPercentY) { try { if (!this.maskPath || !this.gaugeSvg) { this._handleError(new Error('maskPath 또는 gaugeSvg 요소를 찾을 수 없습니다.'), 'findClosestPathPercent'); return 0; } // 입력값 유효성 검증 if (typeof markerPercentX !== 'number' || isNaN(markerPercentX) || typeof markerPercentY !== 'number' || isNaN(markerPercentY)) { this._handleError(new Error(`유효하지 않은 좌표 값: (${markerPercentX}, ${markerPercentY})`), 'findClosestPathPercent'); return 0; } if (this.pathLength === 0) { this.pathLength = this.maskPath.getTotalLength(); if (this.pathLength === 0) { this._handleError(new Error('pathLength가 0입니다.'), 'findClosestPathPercent'); return 0; } } const viewBox = this.gaugeSvg.viewBox.baseVal; if (!viewBox || !viewBox.width || !viewBox.height) { this._handleError(new Error('viewBox가 유효하지 않습니다.'), 'findClosestPathPercent'); return 0; } const markerX = (markerPercentX / 100) * viewBox.width; const markerY = (markerPercentY / 100) * viewBox.height; // maskPath를 따라 여러 지점을 샘플링하여 가장 가까운 지점 찾기 const samples = 200; // 샘플링 개수 (정확도와 성능의 균형) let closestDistance = Infinity; let closestPercent = 0; for (let i = 0; i <= samples; i++) { try { const percent = i / samples; const point = this.maskPath.getPointAtLength(this.pathLength * percent); if (!point) { continue; } // 마커 위치와의 거리 계산 const dx = point.x - markerX; const dy = point.y - markerY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < closestDistance) { closestDistance = distance; closestPercent = percent; } } catch (error) { // 개별 샘플링 에러는 무시하고 계속 진행 continue; } } const resultPercent = closestPercent; return Math.max(0, Math.min(1, resultPercent)); } catch (error) { this._handleError(error, 'findClosestPathPercent', { markerPercentX, markerPercentY }); return 0; } } /** * PC/모바일 상태 업데이트 (리사이즈 시 호출) * isMobile 플래그와 gaugeSvg, maskPath를 현재 창 너비에 맞게 전환 */ updateMobileState() { try { const newIsMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; if (newIsMobile === this.isMobile) return; // 변경 없으면 스킵 this.isMobile = newIsMobile; const gaugeSvgId = this.isMobile ? 'gauge-svg-mo' : 'gauge-svg'; const maskPathId = this.isMobile ? 'maskPath-mo' : 'maskPath'; this.maskPath = this.domUtils?.('#' + maskPathId) || document.getElementById(maskPathId); this.gaugeSvg = this.domUtils?.('#' + gaugeSvgId) || document.getElementById(gaugeSvgId); this.pathLength = 0; // 재계산을 위해 초기화 console.log(`[GaugeManager] 상태 전환: ${this.isMobile ? '모바일' : 'PC'}`); } catch (error) { this._handleError(error, 'updateMobileState'); } } /** * 초기 진행률 계산 (타겟 마커 config 반환) * @param {Array} allMarkers - 전체 마커 배열 * @param {Object} config - 설정 객체 * @returns {Object|null} 타겟 마커 config 객체 */ calculateInitialProgress(allMarkers, config) { try { // 입력값 유효성 검증 if (!allMarkers || !Array.isArray(allMarkers)) { this._handleError(new Error('allMarkers가 배열이 아닙니다.'), 'calculateInitialProgress'); return null; } if (!config || typeof config !== 'object') { this._handleError(new Error('config가 유효하지 않습니다.'), 'calculateInitialProgress'); return null; } const settings = config?.settings || {}; if (settings.allowDisabledClick) { // 비활성 마커 클릭 허용 모드: 완료된 개수만큼 앞에서부터 채우기 return this._calculateProgressByCount(allMarkers); } else { // 순차 학습 모드: 다음 학습 위치 return this._calculateProgressBySequence(allMarkers); } } catch (error) { this._handleError(error, 'calculateInitialProgress', { allMarkers, config }); return null; } } /** * 완료된 개수 기준 진행률 계산 (allowDisabledClick: true) * @private */ _calculateProgressByCount(allMarkers) { try { if (!allMarkers || !Array.isArray(allMarkers)) { this._handleError(new Error('allMarkers가 배열이 아닙니다.'), '_calculateProgressByCount'); return null; } // 실제 강의만 필터링 (챕터 제외) const learningMarkers = allMarkers.filter( (m) => m && m.isLearningContent !== false ); const completedLearningCount = learningMarkers.filter( (m) => m && m.completed === true ).length; if (completedLearningCount === 0) { // 완료된 학습 없음 → 첫 번째 챕터 마커까지 const firstChapterMarker = allMarkers.find( (m) => m && m.isChapterMarker === true ); if (firstChapterMarker) { console.log( `[GaugeManager] 완료 기준 진행률: 첫 챕터 마커 (0개 강의 완료)` ); return firstChapterMarker; // 마커 config 반환 } return null; } if (completedLearningCount >= learningMarkers.length) { // 모든 강의 완료 → 마지막 마커 위치 const lastMarker = allMarkers[allMarkers.length - 1]; if (lastMarker) { console.log( `[GaugeManager] 완료 기준 진행률: 마지막 마커 (전체 ${learningMarkers.length}개 강의 완료)` ); return lastMarker; // 마커 config 반환 } return null; } // 다음 학습할 강의 위치 (현재 학습 중인 마커) const nextLearningMarker = learningMarkers[completedLearningCount]; if (!nextLearningMarker) { console.warn(`[GaugeManager] 다음 학습 마커를 찾을 수 없습니다. (인덱스: ${completedLearningCount})`); return null; } const nextMarkerIndex = allMarkers.findIndex( (m) => m && m.pathPercent === nextLearningMarker.pathPercent && m.label === nextLearningMarker.label ); if (nextMarkerIndex === -1) { console.warn(`[GaugeManager] 타겟 마커를 찾을 수 없습니다.`); return null; } const targetMarker = allMarkers[nextMarkerIndex]; if (!targetMarker) { console.warn(`[GaugeManager] 타겟 마커가 null입니다.`); return null; } console.log( `[GaugeManager] 완료 기준 진행률: ${completedLearningCount}/${learningMarkers.length}개 강의 완료, 현재 학습: ${nextLearningMarker.label}` ); return targetMarker; // 마커 config 반환 } catch (error) { this._handleError(error, '_calculateProgressByCount'); return null; } } /** * 순차 학습 기준 진행률 계산 (allowDisabledClick: false) * @private */ _calculateProgressBySequence(allMarkers) { try { if (!allMarkers || !Array.isArray(allMarkers)) { this._handleError(new Error('allMarkers가 배열이 아닙니다.'), '_calculateProgressBySequence'); return null; } // 실제 강의만 필터링 (챕터 제외) const learningMarkers = allMarkers.filter( (m) => m && m.isLearningContent !== false ); // 마지막으로 완료된 강의의 인덱스 찾기 (순차적) let lastCompletedLearningIndex = -1; for (let i = 0; i < learningMarkers.length; i++) { if (learningMarkers[i] && learningMarkers[i].completed === true) { lastCompletedLearningIndex = i; } else { // 완료되지 않은 학습을 만나면 중단 break; } } // 완료된 강의가 없는 경우 → 첫 번째 챕터 마커까지 if (lastCompletedLearningIndex === -1) { const firstChapterMarker = allMarkers.find( (m) => m && m.isChapterMarker === true ); if (firstChapterMarker) { console.log( `[GaugeManager] 순차 진행률: 첫 챕터 마커 (강의 완료 없음)` ); return firstChapterMarker; // 마커 config 반환 } return null; } // 모든 강의가 완료된 경우 → 마지막 마커 위치 if (lastCompletedLearningIndex === learningMarkers.length - 1) { const lastMarker = allMarkers[allMarkers.length - 1]; if (lastMarker) { console.log( `[GaugeManager] 순차 진행률: 마지막 마커 (전체 ${learningMarkers.length}개 강의 완료)` ); return lastMarker; // 마커 config 반환 } return null; } // 다음 학습할 강의 위치 (현재 학습 중인 마커) const nextIndex = lastCompletedLearningIndex + 1; if (nextIndex >= learningMarkers.length) { console.warn(`[GaugeManager] 다음 학습 인덱스가 범위를 벗어났습니다.`); return null; } const nextLearningMarker = learningMarkers[nextIndex]; if (!nextLearningMarker) { console.warn(`[GaugeManager] 다음 학습 마커를 찾을 수 없습니다.`); return null; } const nextMarkerIndex = allMarkers.findIndex( (m) => m && m.pathPercent === nextLearningMarker.pathPercent && m.label === nextLearningMarker.label ); if (nextMarkerIndex === -1) { console.warn(`[GaugeManager] 타겟 마커를 찾을 수 없습니다.`); return null; } const targetMarker = allMarkers[nextMarkerIndex]; if (!targetMarker) { console.warn(`[GaugeManager] 타겟 마커가 null입니다.`); return null; } console.log( `[GaugeManager] 순차 진행률: 현재 학습: ${nextLearningMarker.label}` ); return targetMarker; // 마커 config 반환 } catch (error) { this._handleError(error, '_calculateProgressBySequence'); return null; } } }