1360 lines
48 KiB
JavaScript
1360 lines
48 KiB
JavaScript
/**
|
|
* 마커 관리 클래스
|
|
* 공통 모듈 활용 (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 = `
|
|
<span><em>${userName}</em>님,</span> 수고하셨습니다.
|
|
`;
|
|
}
|
|
|
|
// p 태그 업데이트
|
|
const p = this.domUtils?.$("p", pageTitle) || pageTitle.querySelector("p");
|
|
if (p) {
|
|
const currentYear = new Date().getFullYear();
|
|
p.innerHTML = `
|
|
<em>${currentYear}년 법정의무교육</em> 시청을
|
|
완료하셨습니다.
|
|
`;
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|