/**
* 진행률 표시 관리 클래스
* 공통 모듈 활용 (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 = `
`;
// 게이지 컨테이너에 추가
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');
}
}
}