/** * 마커 관리 클래스 * 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils) */ class MarkerManager { constructor(gaugeManager, config, 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.gaugeManager = gaugeManager; this.config = config; this.markers = []; this.gaugeSvg = this.gaugeManager.gaugeSvg || this.domUtils?.$("#gauge-svg") || document.getElementById("gauge-svg") || document.getElementById("gauge-svg-mo"); this.markersContainer = this.domUtils?.$("#markers-container") || document.getElementById("markers-container"); if (!this.gaugeSvg) { this._handleError(new Error('gauge-svg 요소를 찾을 수 없습니다.'), 'constructor'); } if (!this.markersContainer) { this._handleError(new Error('markers-container 요소를 찾을 수 없습니다.'), 'constructor'); } this.allMarkers = config.getAllMarkers ? config.getAllMarkers() : []; // flat 배열로 저장 this.modalInstance = null; // 모달 인스턴스 저장 this.completionAnimationShown = false; // 완료 애니메이션 표시 여부 this._savingContentIds = new Set(); // 중복 저장 방지 this._pendingLearningSaves = new Map(); // 저장 중 들어온 최신 요청 보관 } catch (error) { this._handleError(error, 'constructor'); } } /** * 에러 처리 헬퍼 * @private */ _handleError(error, context, additionalInfo = {}) { if (this.errorHandler) { this.errorHandler.handle(error, { context: `MarkerManager.${context}`, component: 'MarkerManager', ...additionalInfo }, false); } else { console.error(`[MarkerManager] ${context}:`, error, additionalInfo); } } /** * 모달 인스턴스 설정 * @param {VideoModal} modal - 모달 인스턴스 */ setModalInstance(modal) { this.modalInstance = modal; } /** * Start-line 이미지 업데이트 * @private */ _updateStartLine() { try { const startLineImg = this.domUtils?.$(".start-line img") || document.querySelector(".start-line img"); if (!startLineImg) { console.warn("[MarkerManager] .start-line img 요소를 찾을 수 없습니다."); return; } // 실제 강의만 카운트 (챕터 제외) const learningMarkers = (this.allMarkers || []).filter( (m) => m && m.isLearningContent !== false ); const completedCount = learningMarkers.filter((m) => m && m.completed === true).length; const allCompleted = completedCount >= learningMarkers.length; // 완료된 학습이 1개 이상 있고, 모든 학습이 완료되지 않았으면 on // 완료된 학습이 없거나, 모든 학습이 완료되었으면 off const hasCompletedLessons = completedCount > 0; const shouldShowOn = hasCompletedLessons && !allCompleted; const imagePath = shouldShowOn ? "/img/learning/img_start_on.svg" : "/img/learning/img_start_off.svg"; startLineImg.src = imagePath; console.log( `[MarkerManager] Start-line 상태: ${shouldShowOn ? "ON" : "OFF"} (완료된 학습 ${completedCount}/${learningMarkers.length}개, 전체 완료: ${allCompleted})` ); } catch (error) { this._handleError(error, '_updateStartLine'); } } /** * 모든 마커 생성 */ createMarkers() { try { if (!this.gaugeSvg) { this._handleError(new Error('gaugeSvg가 없습니다.'), 'createMarkers'); return; } if (!this.markersContainer) { this._handleError(new Error('markersContainer가 없습니다.'), 'createMarkers'); return; } // SVG 컨테이너 크기 가져오기 (초기 계산용) const viewBox = this.gaugeSvg.viewBox.baseVal; if (!viewBox || !viewBox.width || !viewBox.height) { this._handleError(new Error('viewBox가 유효하지 않습니다.'), 'createMarkers'); return; } if (!this.allMarkers || !Array.isArray(this.allMarkers)) { this._handleError(new Error('allMarkers가 배열이 아닙니다.'), 'createMarkers'); return; } this.allMarkers.forEach((config, index) => { try { if ( this.config?.settings?.useLessonMarkers === false && config && config.isChapterMarker !== true ) { return; } this._createMarker(config, index, viewBox); } catch (error) { this._handleError(error, 'createMarkers.marker', { index }); } }); // 마커 생성 후 즉시 이미지 업데이트 this.updateMarkers(); // Start-line 상태 업데이트 this._updateStartLine(); // 초기 로드 시 모든 학습이 완료 상태면 배경 표시 (애니메이션 없이) const allCompleted = this.allMarkers.every((marker) => marker && marker.completed === true); if (allCompleted) { this._showCompletionBackgroundStatic(); this.completionAnimationShown = true; // 플래그 설정하여 중복 방지 } // 윈도우 리사이즈 시 마커 위치 재계산 this._setupResizeHandler(); } catch (error) { this._handleError(error, 'createMarkers'); } } /** * 리사이즈 핸들러 설정 * @private */ _setupResizeHandler() { try { const resizeHandler = () => { try { if (this.gaugeManager && typeof this.gaugeManager.updateMobileState === 'function') { this.gaugeManager.updateMobileState(); } this.gaugeSvg = this.gaugeManager?.gaugeSvg || this.gaugeSvg; console.log("[MarkerManager] 리사이즈 감지: 마커 위치 재계산"); this._updateMarkerPositions(); } 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); } } // 모바일 ↔ PC 전환 시 페이지 새로고침 const mq = window.matchMedia('(max-width: 767px)'); mq.addEventListener('change', () => location.reload()); } catch (error) { this._handleError(error, '_setupResizeHandler'); } } /** * 마커 위치 업데이트 (리사이즈 시) * @private */ _updateMarkerPositions() { try { if (!this.gaugeSvg || !this.gaugeManager) { console.warn("[MarkerManager] gaugeSvg 또는 gaugeManager가 없습니다."); return; } const viewBox = this.gaugeSvg.viewBox.baseVal; if (!viewBox || !viewBox.width || !viewBox.height) { console.warn("[MarkerManager] viewBox가 유효하지 않습니다."); return; } this.markers.forEach((marker, index) => { try { if (!marker || !marker.config || !marker.element) { console.warn(`[MarkerManager] 마커 ${index}가 유효하지 않습니다.`); return; } const isMobile = this.gaugeManager.isMobile; const pathPercent = (isMobile && marker.config.pathPercentMo != null) ? marker.config.pathPercentMo : (marker.config.pathPercent || 0); const point = this.gaugeManager.getPointAtPercent(pathPercent); if (!point) { console.warn(`[MarkerManager] 마커 ${index}의 포인트를 가져올 수 없습니다.`); return; } const percentX = (point.x / viewBox.width) * 100; const percentY = (point.y / viewBox.height) * 100; if (this.domUtils) { this.domUtils.setStyles(marker.element, { left: `${percentX}%`, top: `${percentY}%` }); } else { marker.element.style.left = `${percentX}%`; marker.element.style.top = `${percentY}%`; } } catch (error) { this._handleError(error, '_updateMarkerPositions.marker', { index }); } }); } catch (error) { this._handleError(error, '_updateMarkerPositions'); } } /** * 개별 마커 생성 * @private */ _createMarker(config, index, viewBox) { try { if (!config) { console.warn(`[MarkerManager] 마커 ${index}의 config가 없습니다.`); return; } if (!this.markersContainer) { this._handleError(new Error('markersContainer가 없습니다.'), '_createMarker'); return; } if (!viewBox || !viewBox.width || !viewBox.height) { this._handleError(new Error('viewBox가 유효하지 않습니다.'), '_createMarker'); return; } const isMobile = this.gaugeManager.isMobile; const pathPercent = (isMobile && config.pathPercentMo != null) ? config.pathPercentMo : (config.pathPercent || 0); const point = this.gaugeManager.getPointAtPercent(pathPercent); if (!point) { console.warn(`[MarkerManager] 마커 ${index}의 포인트를 가져올 수 없습니다.`); return; } const marker = this.domUtils?.createElement('div', { class: 'marker' }) || document.createElement("div"); marker.className = "marker"; if (config.type === "chapter") { this.domUtils?.addClasses(marker, 'chapter') || marker.classList.add("chapter"); } marker.dataset.index = index; marker.dataset.type = config.type || 'normal'; marker.dataset.percent = config.pathPercent || 0; const img = this.domUtils?.createElement('img', { style: { height: 'auto' }, loading: 'lazy' }) || document.createElement("img"); if (!this.domUtils) { img.style.height = "auto"; img.loading = "lazy"; } marker.appendChild(img); this.markersContainer.appendChild(marker); // 퍼센트 기반 위치 계산 const percentX = (point.x / viewBox.width) * 100; const percentY = (point.y / viewBox.height) * 100; if (this.domUtils) { this.domUtils.setStyles(marker, { left: `${percentX}%`, top: `${percentY}%`, cursor: "default", pointerEvents: "none" }); } else { marker.style.left = `${percentX}%`; marker.style.top = `${percentY}%`; marker.style.cursor = "default"; marker.style.pointerEvents = "none"; // 클릭 이벤트 완전 차단 } console.log( `[MarkerManager] 마커 [${index}] 생성: (${percentX.toFixed(2)}%, ${percentY.toFixed(2)}%)` ); this._setMarkerState(marker, index); this.markers.push({ element: marker, img: img, config: config, point: point, globalIndex: index, }); } catch (error) { this._handleError(error, '_createMarker', { index, config }); } } /** * 마커 상태 설정 * @private */ _setMarkerState(marker, index) { const config = this.allMarkers[index]; // 기존 상태 클래스 제거 marker.classList.remove("current", "completed"); if (config.completed) { // 완료된 마커 marker.classList.add("completed"); } else if (this.isMarkerClickable(index)) { // 현재 학습 가능한 마커 (current) marker.classList.add("current"); } console.log( `[MarkerManager] 마커 [${index}] 상태: ${config.completed ? "completed" : this.isMarkerClickable(index) ? "current" : "base"}` ); } /** * 마커 클릭 가능 여부 확인 (진행 상태 판단용) * @param {number} index - 마커 인덱스 * @returns {boolean} */ isMarkerClickable(index) { const currentMarker = this.allMarkers[index]; // 챕터 마커는 시작점 표시용이므로 클릭 불가 if (currentMarker.isChapterMarker) { return false; } // 첫 번째 실제 강의는 항상 활성화 if (index === 0) return true; // 이전 마커 확인 (챕터 마커는 건너뛰고 실제 강의만 체크) for (let i = index - 1; i >= 0; i--) { const prevMarker = this.allMarkers[i]; // 챕터 마커는 건너뛰기 if (prevMarker.isChapterMarker) { continue; } // 이전 실제 강의가 완료되었으면 현재 마커 활성화 return prevMarker.completed; } // 이전에 실제 강의가 없으면 활성화 return true; } /** * 마커 상태 업데이트 * @param {number} progress - 현재 진행률 (선택적) */ updateMarkers(progress) { this.markers.forEach((marker, index) => { // allMarkers에서 최신 상태 가져오기 (표시된 마커의 글로벌 인덱스 기준) const globalIndex = Number.isInteger(marker.globalIndex) ? marker.globalIndex : index; const config = this.allMarkers[globalIndex]; if (!config) { return; } const imgSrc = this._getMarkerImage(config, globalIndex); marker.img.src = imgSrc; // marker.config도 업데이트하여 참조 동기화 marker.config = config; // ========== 이미지에 따른 클래스 업데이트 ========== const element = marker.element; element.classList.remove("current", "completed"); // 이미지 경로에서 상태 판단 if ( imgSrc.includes("_completed.png") || imgSrc.includes("_complete.png") ) { element.classList.add("completed"); } else if (imgSrc.includes("_current.png")) { element.classList.add("current"); } // ================================================== }); } /** * 마커 이미지 결정 * @private */ _getMarkerImage(config, index) { const images = this.config.markerImages[config.type]; // 챕터 마커인 경우 특별 처리 if (config.isChapterMarker) { // 하위 lessons가 모두 완료되면 completed if (config.completed) { return images.completed; } // 하위 lessons 중 1개라도 완료 또는 시청 이력이 있으면 current // config.chapters 대신 allMarkers를 기준으로 판정해 저장 직후 상태를 즉시 반영한다. const anyLessonStarted = (this.allMarkers || []).some((marker) => { if (!marker || marker.isChapterMarker) return false; if (marker.chapterId !== config.chapterId) return false; const watchTm = Math.max(0, Number.parseInt(marker.watch_tm ?? 0, 10) || 0); const allTm = Math.max(0, Number.parseInt(marker.all_tm ?? 0, 10) || 0); return marker.completed === true || watchTm > 0 || allTm > 0; }); if (anyLessonStarted) { return images.current; // mark_chapter_current.png 반환 } // 챕터가 활성화되었는지 체크 (이전 챕터 완료) if (this._isChapterActive(index)) { return images.current; // mark_chapter_current.png 반환 } return images.base; } // 일반 마커인 경우 if (config.completed) { return images.completed; } else if (this.isMarkerClickable(index) && !config.completed) { return images.current; } else { return images.base; } } /** * 챕터 마커 활성화 여부 확인 * @private */ _isChapterActive(chapterMarkerIndex) { if (chapterMarkerIndex === 0) return true; // 첫 챕터는 항상 활성 // 이전 마커들 중 마지막 실제 강의가 완료되었는지 확인 for (let i = chapterMarkerIndex - 1; i >= 0; i--) { const prevMarker = this.allMarkers[i]; // 챕터 마커는 건너뛰기 if (prevMarker.isChapterMarker) { continue; } // 이전 실제 강의가 완료되었으면 현재 챕터 활성화 return prevMarker.completed; } return true; } /** * 마커 클릭 가능 여부 업데이트 */ updateMarkerClickability() { this.markers.forEach((marker, index) => { const element = marker.element; const config = marker.config; // 기존 상태 클래스 제거 element.classList.remove("current", "completed"); // 상태에 따라 클래스 업데이트 if (config.completed) { element.classList.add("completed"); } else if (this.isMarkerClickable(index)) { element.classList.add("current"); } }); console.log("[MarkerManager] 마커 상태 업데이트 완료"); } /** * allMarkers의 학습 상태를 원본 config.chapters.lessons로 동기화 * 챕터 활성(current) 판정은 원본 lessons를 기준으로 하므로 저장 직후 반영이 필요하다. * @private */ _syncLessonStateToConfig(lessonState) { try { if (!lessonState || lessonState.isChapterMarker) { return; } const chapter = Array.isArray(this.config?.chapters) ? this.config.chapters.find((ch) => ch && ch.id === lessonState.chapterId) : null; if (!chapter || !Array.isArray(chapter.lessons)) { return; } const targetLesson = chapter.lessons.find((l) => { if (!l) return false; const byContentId = String(l.content_id || '') !== '' && String(l.content_id || '') === String(lessonState.content_id || ''); if (byContentId) return true; return l.pathPercent === lessonState.pathPercent && l.label === lessonState.label; }); if (!targetLesson) { return; } targetLesson.watch_tm = Math.max(0, Number.parseInt(lessonState.watch_tm ?? 0, 10) || 0); targetLesson.content_tm = Math.max(0, Number.parseInt(lessonState.content_tm ?? 0, 10) || 0); targetLesson.all_tm = Math.max(0, Number.parseInt(lessonState.all_tm ?? 0, 10) || 0); if (lessonState.completed === true) { targetLesson.completed = true; } } catch (error) { this._handleError(error, '_syncLessonStateToConfig'); } } /** * 학습 완료 처리 * @param {number} index - 완료할 학습 인덱스 */ async _saveLearningHistory(lesson, options = {}) { try { const contentId = String(lesson?.content_id || ''); if (!contentId) { return; } if (this._savingContentIds.has(contentId)) { const queued = this._pendingLearningSaves.get(contentId) || { lesson }; queued.lesson = lesson; queued.options = { watch_tm: Math.max( Number.parseInt(queued.options?.watch_tm ?? 0, 10) || 0, Number.parseInt(options.watch_tm ?? 0, 10) || 0 ), content_tm: Math.max( Number.parseInt(queued.options?.content_tm ?? 0, 10) || 0, Number.parseInt(options.content_tm ?? 0, 10) || 0 ), all_tm_increment: (Number.parseInt(queued.options?.all_tm_increment ?? 0, 10) || 0) + (Number.parseInt(options.all_tm_increment ?? 0, 10) || 0), completed: queued.options?.completed === true || options.completed === true, isWatching: options.isWatching === true, }; this._pendingLearningSaves.set(contentId, queued); return; } this._savingContentIds.add(contentId); const contentTm = Math.max( 0, Number.parseInt(options.content_tm ?? lesson?.content_tm ?? 0, 10) || 0 ); const requestedWatchTm = Math.max(0, Number.parseInt(options.watch_tm ?? 0, 10) || 0); const previousWatchTm = Math.max(0, Number.parseInt(lesson?.watch_tm ?? 0, 10) || 0); let watchTm = Math.max(requestedWatchTm, previousWatchTm); if (options.completed && contentTm > 0) { watchTm = Math.max(watchTm, contentTm); } if (contentTm > 0) { watchTm = Math.min(watchTm, contentTm); } const previousAllTm = Math.max(0, Number.parseInt(lesson?.all_tm ?? 0, 10) || 0); const allTmIncrement = Math.max(0, Number.parseInt(options.all_tm_increment ?? 0, 10) || 0); const fallbackByWatchDelta = allTmIncrement > 0 ? 0 : Math.max(0, watchTm - previousWatchTm); const effectiveIncrement = allTmIncrement + fallbackByWatchDelta; const allTm = previousAllTm + effectiveIncrement; const params = new URLSearchParams({ content_id: contentId, watch_tm: String(watchTm), content_tm: String(contentTm), all_tm: String(allTm), all_tm_increment: String(allTmIncrement), completed: options.completed ? '1' : '0', is_watching: options.isWatching ? 'Y' : 'N', }); const response = await fetch('/bbs/api/save_learning.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, keepalive: true, body: params.toString(), }); const data = await response.json(); if (!response.ok || data?.success !== true) { throw new Error(data?.message || `학습이력 저장 실패 (${response.status})`); } console.log( `[LearningTime] 저장 성공 content_id=${contentId}, watch_tm=${watchTm}, content_tm=${contentTm}, all_tm_increment=${allTmIncrement}, all_tm=${allTm}` ); if (data?.data) { lesson.watch_tm = Number.parseInt(data.data.watch_tm ?? watchTm, 10) || watchTm; lesson.content_tm = Number.parseInt(data.data.content_tm ?? contentTm, 10) || contentTm; lesson.all_tm = Number.parseInt(data.data.all_tm ?? allTm, 10) || allTm; lesson.completed = data.data.completed === true ? true : lesson.completed; } else { lesson.watch_tm = watchTm; lesson.content_tm = contentTm; lesson.all_tm = allTm; } this._syncLessonStateToConfig(lesson); if (typeof this.config?.updateChapterCompletionStatus === 'function') { this.config.updateChapterCompletionStatus(); } this.allMarkers = this.config.getAllMarkers(); this.updateMarkers(); this.updateMarkerClickability(); } catch (error) { console.error('[LearningTime] 저장 실패', { content_id: lesson?.content_id, watch_tm: options?.watch_tm, content_tm: options?.content_tm, all_tm_increment: options?.all_tm_increment, error: error?.message || error, }); this._handleError(error, '_saveLearningHistory', { content_id: lesson?.content_id }); } finally { const contentId = String(lesson?.content_id || ''); if (contentId) { this._savingContentIds.delete(contentId); const pendingSave = this._pendingLearningSaves.get(contentId); if (pendingSave) { this._pendingLearningSaves.delete(contentId); this._saveLearningHistory(pendingSave.lesson, pendingSave.options); } } } } completeLesson(index) { try { // 입력값 유효성 검증 if (typeof index !== 'number' || index < 0) { this._handleError(new Error(`유효하지 않은 index: ${index}`), 'completeLesson'); return; } if (!this.allMarkers || !Array.isArray(this.allMarkers) || index >= this.allMarkers.length) { this._handleError(new Error(`index ${index}가 범위를 벗어났습니다.`), 'completeLesson'); return; } if (!this.allMarkers[index]) { this._handleError(new Error(`마커 ${index}가 null입니다.`), 'completeLesson'); return; } // 1. allMarkers 업데이트 this.allMarkers[index].completed = true; this._saveLearningHistory(this.allMarkers[index], { completed: true, isWatching: false }); // 2. 원본 config의 lessons도 업데이트 const markerData = this.allMarkers[index]; const chapterId = markerData.chapterId; const chapter = this.config.chapters.find((ch) => ch.id === chapterId); if (chapter && !markerData.isChapterMarker) { // 실제 강의인 경우 원본 lessons 배열에서 찾아서 업데이트 const lesson = chapter.lessons.find( (l) => l.pathPercent === markerData.pathPercent && l.label === markerData.label ); if (lesson) { lesson.completed = true; console.log( `[MarkerManager] 원본 config 업데이트: ${lesson.label} completed = true` ); } } // 3. 챕터 완료 상태 업데이트 this.config.updateChapterCompletionStatus(); // 4. allMarkers 재로드 (챕터 completed 상태 반영) this.allMarkers = this.config.getAllMarkers(); const settings = this.config.settings || {}; let progressPercent; // 실제 강의만 카운트 (챕터 제외) const learningMarkers = this.allMarkers.filter( (m) => m.isLearningContent !== false ); const completedLearningCount = learningMarkers.filter( (m) => m.completed ).length; let targetPathPercent = 0; let targetConfig = null; if (settings.allowDisabledClick) { // 비활성 마커 클릭 허용 모드: 다음 학습 마커까지 if (completedLearningCount === 0) { // 첫 번째 챕터 마커까지 const firstChapterMarker = this.allMarkers.find( (m) => m.isChapterMarker === true ); targetConfig = firstChapterMarker; } else if (completedLearningCount >= learningMarkers.length) { // 전체 완료: 마지막 마커 위치 targetConfig = this.allMarkers[this.allMarkers.length - 1]; } else { // 다음 학습할 강의 마커 위치 const nextLearningMarker = learningMarkers[completedLearningCount]; const nextMarkerIndex = this.allMarkers.findIndex( (m) => m.pathPercent === nextLearningMarker.pathPercent && m.label === nextLearningMarker.label ); targetConfig = this.allMarkers[nextMarkerIndex]; } } else { // 순차 학습 모드: 다음 학습 마커까지 if (completedLearningCount === 0) { // 첫 번째 챕터 마커까지 const firstChapterMarker = this.allMarkers.find( (m) => m.isChapterMarker === true ); targetConfig = firstChapterMarker; } else if (completedLearningCount >= learningMarkers.length) { // 전체 완료: 마지막 마커 위치 targetConfig = this.allMarkers[this.allMarkers.length - 1]; } else { // 다음 학습할 강의 마커 위치 const nextLearningMarker = learningMarkers[completedLearningCount]; const nextMarkerIndex = this.allMarkers.findIndex( (m) => m.pathPercent === nextLearningMarker.pathPercent && m.label === nextLearningMarker.label ); targetConfig = this.allMarkers[nextMarkerIndex]; } } // 타겟 마커 찾기 (pathPercent로 비교) const targetMarker = targetConfig ? this.markers.find( (m) => m.config && m.config.pathPercent === targetConfig.pathPercent && m.config.label === targetConfig.label ) : null; // 100% 완료 시 게이지바를 100%로 채움 if (completedLearningCount >= learningMarkers.length) { targetPathPercent = 1.0; // 100% 완료 progressPercent = 100; console.log( `[MarkerManager] 모든 학습 완료: 게이지바 100%로 설정` ); } else if (targetMarker && targetMarker.element) { // gaugePercent가 있으면 우선 사용, 없으면 마커의 실제 DOM 위치 기반으로 계산 (모바일은 gaugePercentMo) const gaugeVal = this.gaugeManager.isMobile && targetConfig.gaugePercentMo != null ? targetConfig.gaugePercentMo : targetConfig.gaugePercent; if (gaugeVal !== undefined) { targetPathPercent = gaugeVal; progressPercent = targetPathPercent * 100; console.log( `[MarkerManager] gaugePercent 사용: ${progressPercent.toFixed(1)}%` ); } else { // 마커의 실제 DOM 위치 가져오기 const markerLeft = parseFloat(targetMarker.element.style.left) || 0; const markerTop = parseFloat(targetMarker.element.style.top) || 0; // maskPath에서 마커 위치에 가장 가까운 지점 찾기 targetPathPercent = this.gaugeManager.findClosestPathPercent(markerLeft, markerTop); progressPercent = targetPathPercent * 100; // 로그용 console.log( `[MarkerManager] 마커 실제 위치 기반: (${markerLeft.toFixed(2)}%, ${markerTop.toFixed(2)}%) → pathPercent: ${targetPathPercent.toFixed(4)} (${progressPercent.toFixed(1)}%)` ); } } else if (targetConfig) { // 마커를 찾을 수 없는 경우 gaugePercent 우선 사용, 없으면 pathPercent 사용 (모바일은 Mo 버전) const gaugeVal = this.gaugeManager.isMobile && targetConfig.gaugePercentMo != null ? targetConfig.gaugePercentMo : targetConfig.gaugePercent; targetPathPercent = gaugeVal !== undefined ? gaugeVal : (targetConfig.pathPercentMo != null && this.gaugeManager.isMobile ? targetConfig.pathPercentMo : (targetConfig.pathPercent || 0)); progressPercent = targetPathPercent * 100; console.log( `[MarkerManager] 마커를 찾을 수 없음, ${targetConfig.gaugePercent !== undefined ? 'gaugePercent' : 'pathPercent'} 직접 사용: ${progressPercent.toFixed(1)}%` ); } // 마커 실제 위치에 가장 가까운 pathPercent를 사용하여 채움 this.gaugeManager.setProgress(targetPathPercent, true); this.updateMarkers(progressPercent); this.updateMarkerClickability(); // Start-line 상태 업데이트 (첫 학습 완료 시 OFF → ON 전환) this._updateStartLine(); // 100% 완료 시 폭죽 애니메이션 표시 (실제 강의 기준) const allLearningCompleted = learningMarkers.every((m) => m.completed); if (allLearningCompleted && !this.completionAnimationShown) { // 기존 정적 배경이 있으면 제거 const existingStaticBg = document.querySelector(".completion-background"); if (existingStaticBg) { existingStaticBg.remove(); } this._showCompletionAnimation(); this.completionAnimationShown = true; // 완료 후 성취 화면으로 이동 (2초 지연) setTimeout(() => { window.location.href = 'learning_2.php'; }, 2000); } // 챕터 카드 상태 업데이트 if (window.learningApp && window.learningApp.updateChapterCards) { window.learningApp.updateChapterCards(); } console.log( `[MarkerManager] 학습 ${index + 1} 완료! (${completedLearningCount}/${learningMarkers.length} 강의) ` + `진행률: ${progressPercent ? progressPercent.toFixed(1) : 0}%` ); } catch (error) { this._handleError(error, 'completeLesson', { index }); } } /** * 수강완료 시 page-title 업데이트 * @private */ _updatePageTitleOnCompletion() { try { const pageTitle = this.domUtils?.$(".page-title") || document.querySelector(".page-title"); if (!pageTitle) { console.warn("[MarkerManager] .page-title 요소를 찾을 수 없습니다."); return; } // 현재 이름 추출 (h3 em 태그에서) const h3 = this.domUtils?.$("h3", pageTitle) || pageTitle.querySelector("h3"); const em = h3 ? (this.domUtils?.$("em", h3) || h3.querySelector("em")) : null; let userName = ""; if (em) { // "님" 제거하여 이름만 추출 userName = em.textContent.replace(/님$/, "").trim(); } // h3 업데이트 if (h3) { h3.innerHTML = ` ${userName}님, 수고하셨습니다. `; } // p 태그 업데이트 const p = this.domUtils?.$("p", pageTitle) || pageTitle.querySelector("p"); if (p) { const currentYear = new Date().getFullYear(); p.innerHTML = ` ${currentYear}년 법정의무교육 시청을 완료하셨습니다. `; } console.log("[MarkerManager] page-title 업데이트 완료"); } catch (error) { this._handleError(error, '_updatePageTitleOnCompletion'); } } /** * 학습 완료 배경 표시 * @private */ _showCompletionAnimation() { try { console.log("[MarkerManager] 🎉 모든 학습 완료! 완료 배경 표시"); // page-title 업데이트 this._updatePageTitleOnCompletion(); // 기존 완료 배경이 있으면 제거 const existingBg = this.domUtils?.$(".completion-background") || document.querySelector(".completion-background"); if (existingBg) { this.domUtils?.remove(existingBg) || existingBg.remove(); } // 완료 배경 컨테이너 생성 const backgroundContainer = this.domUtils?.createElement('div', { class: 'completion-background', style: { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: '1' } }) || document.createElement("div"); if (!this.domUtils) { backgroundContainer.className = "completion-background"; backgroundContainer.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; `; } // SVG 배경 추가 const isMobile = window.matchMedia('(max-width: 767px)').matches; const svgWrapperStyle = isMobile ? { position: 'absolute', top: '0', left: '0', width: '100%', height: 'auto', opacity: '0', animation: 'fadeIn 1.5s ease-in-out forwards' } : { position: 'absolute', top: '49%', left: '51%', width: '100%', height: '100%', opacity: '0', translate: '-50% -50%', animation: 'fadeIn 1.5s ease-in-out forwards' }; const svgWrapper = this.domUtils?.createElement('div', { style: svgWrapperStyle }) || document.createElement("div"); if (!this.domUtils) { svgWrapper.style.cssText = isMobile ? `position: absolute; top: 0; left: 0; width: 100%; height: auto; opacity: 0; animation: fadeIn 1.5s ease-in-out forwards;` : `position: absolute; top: 49%; left: 51%; width: 100%; height: 100%; opacity: 0; translate: -50% -50%; animation: fadeIn 1.5s ease-in-out forwards;`; } // SVG 로드 및 추가 const svgUrl = isMobile ? "/img/learning/completion-bg_m.svg" : "/img/learning/completion-bg.svg"; fetch(svgUrl) .then((response) => { if (!response.ok) { throw new Error(`SVG 로드 실패: ${response.status}`); } return response.text(); }) .then((svgContent) => { try { svgWrapper.innerHTML = svgContent; const svg = svgWrapper.querySelector("svg"); if (svg) { // 패턴 ID 중복 방지: 고유한 ID로 변경 const uniqueId = `completion-bg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const patterns = svg.querySelectorAll( "pattern[id], filter[id], mask[id], linearGradient[id], radialGradient[id]" ); const idMap = new Map(); patterns.forEach((pattern) => { const oldId = pattern.getAttribute("id"); if (oldId) { const newId = `${oldId}-${uniqueId}`; idMap.set(oldId, newId); pattern.setAttribute("id", newId); } }); // 패턴을 참조하는 모든 요소의 url(#...) 업데이트 const allElements = svg.querySelectorAll("*"); allElements.forEach((el) => { // fill, stroke, filter, mask 등 속성 업데이트 ["fill", "stroke", "filter", "mask"].forEach((attr) => { const value = el.getAttribute(attr); if (value && value.startsWith("url(#")) { const match = value.match(/url\(#([^)]+)\)/); if (match) { const oldId = match[1]; if (idMap.has(oldId)) { el.setAttribute(attr, `url(#${idMap.get(oldId)})`); } } } }); }); console.log(`[MarkerManager] 패턴 ID 고유화 완료: ${uniqueId}`); // 흰색 배경 rect/path 요소 제거 (x="-34" y="-19" 또는 fill="white"인 요소) const whiteBgElements = svg.querySelectorAll( 'rect[fill="white"], path[fill="#fff"], path[fill="white"]' ); whiteBgElements.forEach((el) => { const x = el.getAttribute("x"); const y = el.getAttribute("y"); // SVG 파일의 흰색 배경 rect (x="-34" y="-19") 제거 if ( (x === "-34" && y === "-19") || el.getAttribute("fill") === "white" || el.getAttribute("fill") === "#fff" ) { el.remove(); console.log("[MarkerManager] 흰색 배경 요소 제거"); } }); // "수강완료" 텍스트 요소 제거 또는 숨김 const allTexts = svg.querySelectorAll("text, tspan"); allTexts.forEach((textEl) => { const textContent = textEl.textContent || ""; if ( textContent.includes("수강완료") || textContent.includes("완료") ) { textEl.style.display = "none"; console.log("[MarkerManager] '수강완료' 텍스트 요소 숨김"); } }); // 화면 크기에 맞게 viewBox 동적 조정 const screenWidth = window.innerWidth; const screenHeight = window.innerHeight; const screenRatio = screenWidth / screenHeight; let newViewBox; if (!isMobile) { const svgW = 1920, svgH = 918; const svgRatio = svgW / svgH; if (screenRatio > svgRatio) { const newWidth = svgH * screenRatio; const offsetX = (newWidth - svgW) / 2; newViewBox = `${-offsetX} 0 ${newWidth} ${svgH}`; } else { const newHeight = svgW / screenRatio; const offsetY = (newHeight - svgH) / 2; newViewBox = `0 ${-offsetY} ${svgW} ${newHeight}`; } } svg.setAttribute("viewBox", newViewBox); svg.removeAttribute("width"); svg.removeAttribute("height"); svg.setAttribute("preserveAspectRatio", "xMidYMid slice"); svg.style.cssText = ` width: 100%; height: 100%; `; console.log(`[MarkerManager] SVG viewBox 조정: ${newViewBox}`); } } catch (error) { this._handleError(error, '_showCompletionAnimation.svgProcessing'); } }) .catch((error) => { this._handleError(error, '_showCompletionAnimation.fetch'); console.warn("[MarkerManager] SVG 로드 실패"); }); backgroundContainer.appendChild(svgWrapper); // CSS 애니메이션 정의 및 상단 왼쪽 영역 숨김 (처음 표시될 때만) if (!(this.domUtils?.$("#completion-animation-style") || document.getElementById("completion-animation-style"))) { const style = this.domUtils?.createElement('style', { id: 'completion-animation-style' }) || document.createElement("style"); style.id = "completion-animation-style"; style.textContent = ` @keyframes fadeIn { 0% { opacity: 0; transform: scale(0.95); } 100% { opacity: 1; transform: scale(1); } } /* 상단 왼쪽 영역의 "수강완료" 텍스트 숨김 */ .completion-background { overflow: hidden; } .completion-background svg { position: relative; } `; document.head.appendChild(style); } const container = this.domUtils?.$(".container") || document.querySelector(".container"); if (container) { container.appendChild(backgroundContainer); } else { console.warn("[MarkerManager] .container 요소를 찾을 수 없습니다."); } } catch (error) { this._handleError(error, '_showCompletionAnimation'); } } /** * 초기 완료 배경 표시 (애니메이션 없이) * @private */ _showCompletionBackgroundStatic() { try { console.log("[MarkerManager] 학습 완료 상태 - 배경 표시"); // page-title 업데이트 this._updatePageTitleOnCompletion(); // 기존 완료 배경이 있으면 제거 const existingBg = this.domUtils?.$(".completion-background") || document.querySelector(".completion-background"); if (existingBg) { this.domUtils?.remove(existingBg) || existingBg.remove(); // 기존 배경 제거 후 새로 생성 } // 완료 배경 컨테이너 생성 const backgroundContainer = this.domUtils?.createElement('div', { class: 'completion-background', style: { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: '1' } }) || document.createElement("div"); if (!this.domUtils) { backgroundContainer.className = "completion-background"; backgroundContainer.style.cssText = ` position: fixed; top:0; left:0; width: 100%; height: 100%; pointer-events: none; z-index: 1; `; } // SVG 배경 추가 (애니메이션 없이) const svgWrapper = this.domUtils?.createElement('div', { style: { position: 'absolute', top: '49%', left: '51%', width: '100%', height: '100%', opacity: '1', translate: '-50% -50%' } }) || document.createElement("div"); if (!this.domUtils) { svgWrapper.style.cssText = ` position: absolute; top: 49%; left: 51%; width: 100%; height: 100%; opacity: 1; translate: -50% -50%; `; } // SVG 로드 및 추가 fetch("/img/learning/completion-bg.svg") .then((response) => { if (!response.ok) { throw new Error(`SVG 로드 실패: ${response.status}`); } return response.text(); }) .then((svgContent) => { try { svgWrapper.innerHTML = svgContent; const svg = svgWrapper.querySelector("svg"); if (svg) { // 패턴 ID 중복 방지: 고유한 ID로 변경 const uniqueId = `completion-bg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const patterns = svg.querySelectorAll( "pattern[id], filter[id], mask[id], linearGradient[id], radialGradient[id]" ); const idMap = new Map(); patterns.forEach((pattern) => { const oldId = pattern.getAttribute("id"); if (oldId) { const newId = `${oldId}-${uniqueId}`; idMap.set(oldId, newId); pattern.setAttribute("id", newId); } }); // 패턴을 참조하는 모든 요소의 url(#...) 업데이트 const allElements = svg.querySelectorAll("*"); allElements.forEach((el) => { // fill, stroke, filter, mask 등 속성 업데이트 ["fill", "stroke", "filter", "mask"].forEach((attr) => { const value = el.getAttribute(attr); if (value && value.startsWith("url(#")) { const match = value.match(/url\(#([^)]+)\)/); if (match) { const oldId = match[1]; if (idMap.has(oldId)) { el.setAttribute(attr, `url(#${idMap.get(oldId)})`); } } } }); }); console.log(`[MarkerManager] 패턴 ID 고유화 완료: ${uniqueId}`); // 흰색 배경 rect/path 요소 제거 (x="-34" y="-19" 또는 fill="white"인 요소) const whiteBgElements = svg.querySelectorAll( 'rect[fill="white"], path[fill="#fff"], path[fill="white"]' ); whiteBgElements.forEach((el) => { const x = el.getAttribute("x"); const y = el.getAttribute("y"); // SVG 파일의 흰색 배경 rect (x="-34" y="-19") 제거 if ( (x === "-34" && y === "-19") || el.getAttribute("fill") === "white" || el.getAttribute("fill") === "#fff" ) { el.remove(); console.log("[MarkerManager] 흰색 배경 요소 제거"); } }); // "수강완료" 텍스트 요소 제거 또는 숨김 const allTexts = svg.querySelectorAll("text, tspan"); allTexts.forEach((textEl) => { const textContent = textEl.textContent || ""; if ( textContent.includes("수강완료") || textContent.includes("완료") ) { textEl.style.display = "none"; console.log("[MarkerManager] '수강완료' 텍스트 요소 숨김"); } }); // 화면 크기에 맞게 viewBox 동적 조정 const screenWidth = window.innerWidth; const screenHeight = window.innerHeight; const screenRatio = screenWidth / screenHeight; const svgRatio = 1911 / 918; let newViewBox; if (screenRatio > svgRatio) { // 화면이 더 넓음 - 세로 기준으로 viewBox 확장 const newWidth = 918 * screenRatio; const offsetX = (newWidth - 1911) / 2; newViewBox = `${-offsetX} 0 ${newWidth} 918`; } else { // 화면이 더 높음 - 가로 기준으로 viewBox 확장 const newHeight = 1911 / screenRatio; const offsetY = (newHeight - 918) / 2; newViewBox = `0 ${-offsetY} 1911 ${newHeight}`; } svg.setAttribute("viewBox", newViewBox); svg.removeAttribute("width"); svg.removeAttribute("height"); svg.setAttribute("preserveAspectRatio", "xMidYMid slice"); svg.style.cssText = ` width: 100%; height: 100%; `; console.log(`[MarkerManager] SVG viewBox 조정: ${newViewBox}`); } } catch (error) { this._handleError(error, '_showCompletionBackgroundStatic.svgProcessing'); } }) .catch((error) => { this._handleError(error, '_showCompletionBackgroundStatic.fetch'); console.warn("[MarkerManager] SVG 로드 실패"); }); backgroundContainer.appendChild(svgWrapper); const container = this.domUtils?.$(".container") || document.querySelector(".container"); if (container) { container.appendChild(backgroundContainer); } else { console.warn("[MarkerManager] .container 요소를 찾을 수 없습니다."); } } catch (error) { this._handleError(error, '_showCompletionBackgroundStatic'); } } }