Files
edu/js/learning/gauge.js
T

506 lines
18 KiB
JavaScript

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