Initial commit: 교육 프로젝트 배포
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 챕터 카드 관리 클래스 (CSS 기반 디자인)
|
||||
* 개선된 공통 모듈 활용 (EventManager, ErrorHandler, DOMUtils)
|
||||
*/
|
||||
class ChapterCardManager {
|
||||
constructor(config, gaugeManager, dependencies = {}) {
|
||||
this.config = config;
|
||||
this.gaugeManager = gaugeManager;
|
||||
this.chapterCards = [];
|
||||
this.cardsContainer = null;
|
||||
this.modalInstance = null;
|
||||
|
||||
// 의존성 주입 (폴백 포함)
|
||||
this.domUtils = dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null);
|
||||
this.eventManager = dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : 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);
|
||||
|
||||
// 이벤트 리스너 ID 저장 (정리용)
|
||||
this.listenerIds = [];
|
||||
|
||||
// CSS 스타일 주입
|
||||
this._injectStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 스타일 주입
|
||||
* @private
|
||||
*/
|
||||
_injectStyles() {
|
||||
try {
|
||||
if (document.getElementById("chapter-card-styles")) return;
|
||||
|
||||
const style = this.domUtils?.createElement('style', { id: 'chapter-card-styles' }) || document.createElement("style");
|
||||
style.id = "chapter-card-styles";
|
||||
style.textContent = `
|
||||
.chapter-card:hover .card-play-button {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 호버 효과 */
|
||||
.chapter-card:hover .chapter-card-inner {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.chapter-card.current:hover .chapter-card-inner {
|
||||
box-shadow: 0 12px 32px rgba(31, 155, 118, 0.3);
|
||||
}
|
||||
|
||||
.chapter-card.completed:hover .chapter-card-inner {
|
||||
box-shadow: 0 12px 32px rgba(171, 61, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._injectStyles');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 인스턴스 설정
|
||||
* @param {VideoModal} modal - 모달 인스턴스
|
||||
*/
|
||||
setModalInstance(modal) {
|
||||
this.modalInstance = modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 챕터 카드 생성
|
||||
*/
|
||||
createChapterCards() {
|
||||
try {
|
||||
const gaugeElement = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge");
|
||||
if (!gaugeElement) {
|
||||
console.warn('[ChapterCardManager] .lessons-gauge 요소를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cardsContainer = this.domUtils?.$(".chapter-list", gaugeElement) || gaugeElement.querySelector(".chapter-list");
|
||||
|
||||
if (!this.cardsContainer) {
|
||||
this.cardsContainer = this.domUtils?.createElement('ul', { class: 'chapter-list' }) || document.createElement("ul");
|
||||
this.cardsContainer.className = "chapter-list";
|
||||
gaugeElement.appendChild(this.cardsContainer);
|
||||
}
|
||||
|
||||
this.domUtils?.empty(this.cardsContainer) || (this.cardsContainer.innerHTML = "");
|
||||
|
||||
this.chapterCards = [];
|
||||
|
||||
this.config.chapters.forEach((chapter, chapterIndex) => {
|
||||
// 새 구조: 챕터 자체가 마커이므로 chapter에서 직접 정보 가져오기
|
||||
if (chapter.type === "chapter") {
|
||||
this._createCard(chapter, chapterIndex, chapter);
|
||||
}
|
||||
});
|
||||
|
||||
this._setupResizeHandler();
|
||||
|
||||
console.log(
|
||||
`[ChapterCardManager] ${this.chapterCards.length}개의 챕터 카드 생성 완료`
|
||||
);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager.createChapterCards');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리사이즈 핸들러 설정 (PC/모바일 전환 시 카드 위치 재계산)
|
||||
* @private
|
||||
*/
|
||||
_setupResizeHandler() {
|
||||
try {
|
||||
const resizeHandler = () => {
|
||||
try {
|
||||
if (this.gaugeManager && typeof this.gaugeManager.updateMobileState === 'function') {
|
||||
this.gaugeManager.updateMobileState();
|
||||
}
|
||||
this._repositionCards();
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._setupResizeHandler.resizeHandler');
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
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, 'ChapterCardManager._setupResizeHandler');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 위치 재설정 (리사이즈 시)
|
||||
* @private
|
||||
*/
|
||||
_repositionCards() {
|
||||
try {
|
||||
this.chapterCards.forEach((card) => {
|
||||
if (card && card.element && card.chapterLesson) {
|
||||
this._positionCard(card.element, card.chapterLesson);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._repositionCards');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 챕터 카드 생성
|
||||
* @private
|
||||
*/
|
||||
_createCard(chapter, chapterIndex, chapterLesson) {
|
||||
try {
|
||||
const li = this.domUtils?.createElement('li', { class: 'chapter-card' }) || document.createElement("li");
|
||||
li.classList.add("chapter-card");
|
||||
|
||||
const state = this._getChapterState(chapter);
|
||||
if (state) {
|
||||
this.domUtils?.addClasses(li, state) || li.classList.add(state);
|
||||
}
|
||||
|
||||
li.style.cursor = "pointer";
|
||||
|
||||
// 이벤트 리스너 등록 (EventManager 사용)
|
||||
const clickHandler = () => {
|
||||
this._handleCardClick(chapter, chapterIndex);
|
||||
};
|
||||
|
||||
if (this.eventManager) {
|
||||
const listenerId = this.eventManager.on(li, "click", clickHandler);
|
||||
this.listenerIds.push({ element: li, id: listenerId });
|
||||
} else {
|
||||
li.addEventListener("click", clickHandler);
|
||||
}
|
||||
|
||||
const cardContent = this._createCardContent(chapter, state, chapterIndex);
|
||||
li.appendChild(cardContent);
|
||||
|
||||
this._positionCard(li, chapterLesson);
|
||||
|
||||
this.cardsContainer.appendChild(li);
|
||||
|
||||
this.chapterCards.push({
|
||||
element: li,
|
||||
chapter: chapter,
|
||||
state: state,
|
||||
chapterIndex: chapterIndex,
|
||||
chapterLesson: chapterLesson,
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._createCard', { chapter, chapterIndex });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 콘텐츠 생성
|
||||
* @private
|
||||
*/
|
||||
_createCardContent(chapter, state, chapterIndex) {
|
||||
try {
|
||||
const inner = this.domUtils?.createElement('div', { class: 'chapter-card-inner' }) || document.createElement("div");
|
||||
inner.className = "chapter-card-inner";
|
||||
|
||||
// 썸네일 영역
|
||||
const thumbnailContainer = this.domUtils?.createElement('div', { class: 'card-thumbnail-container' }) || document.createElement("div");
|
||||
thumbnailContainer.className = "card-thumbnail-container";
|
||||
|
||||
const thumbnail = this.domUtils?.createElement('img', {
|
||||
class: 'card-thumbnail',
|
||||
src: `/img/learning/img_learning_0${(chapterIndex % 6) + 1}.jpg`,
|
||||
alt: chapter.name,
|
||||
loading: 'lazy'
|
||||
}) || document.createElement("img");
|
||||
|
||||
if (!this.domUtils) {
|
||||
thumbnail.className = "card-thumbnail";
|
||||
thumbnail.src = `/img/learning/img_learning_0${(chapterIndex % 6) + 1}.jpg`;
|
||||
thumbnail.alt = chapter.name;
|
||||
thumbnail.loading = "lazy";
|
||||
}
|
||||
|
||||
thumbnailContainer.appendChild(thumbnail);
|
||||
|
||||
// 플레이 버튼
|
||||
const playButton = this.domUtils?.createElement('img', {
|
||||
class: 'card-play-button',
|
||||
src: this._getPlayButtonImagePath(state),
|
||||
alt: '재생',
|
||||
loading: 'lazy'
|
||||
}) || document.createElement("img");
|
||||
|
||||
if (!this.domUtils) {
|
||||
playButton.className = "card-play-button";
|
||||
playButton.src = this._getPlayButtonImagePath(state);
|
||||
playButton.alt = "재생";
|
||||
playButton.loading = "lazy";
|
||||
}
|
||||
|
||||
thumbnailContainer.appendChild(playButton);
|
||||
|
||||
// 게이지바 추가
|
||||
const gaugeBar = this.domUtils?.createElement('div', { class: 'card-gauge-bar' }) || document.createElement("div");
|
||||
gaugeBar.className = "card-gauge-bar";
|
||||
|
||||
const gaugeFill = this.domUtils?.createElement('div', { class: 'card-gauge-fill' }) || document.createElement("div");
|
||||
gaugeFill.className = "card-gauge-fill";
|
||||
|
||||
const progressPercent = this._calculateChapterProgress(chapter);
|
||||
gaugeFill.style.width = progressPercent + "%";
|
||||
|
||||
gaugeBar.appendChild(gaugeFill);
|
||||
thumbnailContainer.appendChild(gaugeBar);
|
||||
inner.appendChild(thumbnailContainer);
|
||||
|
||||
// 제목
|
||||
const title = this.domUtils?.createElement('div', { class: 'card-title' }, chapter.name) || document.createElement("div");
|
||||
if (!this.domUtils) {
|
||||
title.className = "card-title";
|
||||
title.textContent = chapter.name;
|
||||
}
|
||||
inner.appendChild(title);
|
||||
|
||||
// 스탬프
|
||||
const stamp = this.domUtils?.createElement('div', { class: 'card-stamp' }) || document.createElement("div");
|
||||
stamp.className = "card-stamp";
|
||||
inner.appendChild(stamp);
|
||||
|
||||
// 그림자
|
||||
const shadow = this.domUtils?.createElement('div', { class: 'shadow-effect' }) || document.createElement("div");
|
||||
shadow.className = "shadow-effect";
|
||||
inner.appendChild(shadow);
|
||||
|
||||
return inner;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._createCardContent', { chapter, state, chapterIndex });
|
||||
// 에러 발생 시 최소한의 요소라도 반환
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "chapter-card-inner";
|
||||
fallback.textContent = chapter.name || "Chapter";
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 클릭 핸들러
|
||||
* @private
|
||||
*/
|
||||
_handleCardClick(chapter, chapterIndex) {
|
||||
try {
|
||||
if (!this.modalInstance) {
|
||||
console.warn('[ChapterCardManager] 모달 인스턴스가 설정되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ChapterCardManager] 챕터 카드 클릭: ${chapter.name} (챕터 ${chapterIndex + 1})`
|
||||
);
|
||||
|
||||
// 챕터는 시작점 표시용이므로 항상 첫 번째 미완료 lesson부터 시작
|
||||
let targetLessonIndex = 0; // 첫 번째 lesson
|
||||
|
||||
// 첫 번째 미완료 lesson 찾기
|
||||
for (let i = 0; i < chapter.lessons.length; i++) {
|
||||
if (!chapter.lessons[i].completed) {
|
||||
targetLessonIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 lesson이 완료된 경우 첫 번째 lesson으로
|
||||
if (targetLessonIndex >= chapter.lessons.length) {
|
||||
targetLessonIndex = 0;
|
||||
}
|
||||
|
||||
const globalIndex = this.config.toGlobalIndex(
|
||||
chapterIndex,
|
||||
targetLessonIndex
|
||||
);
|
||||
|
||||
const targetLesson = chapter.lessons[targetLessonIndex];
|
||||
if (!targetLesson) {
|
||||
console.error('[ChapterCardManager] 대상 lesson을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLabel = targetLesson.label;
|
||||
|
||||
console.log(
|
||||
`[ChapterCardManager] 대상 학습: ${targetLabel} (글로벌 인덱스: ${globalIndex})`
|
||||
);
|
||||
|
||||
this.modalInstance.loadChapter(chapter, chapterIndex, globalIndex);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._handleCardClick', { chapter, chapterIndex });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 챕터 상태 결정
|
||||
* @private
|
||||
*/
|
||||
_getChapterState(chapter) {
|
||||
// 새 구조: chapter.completed 사용 (자동 업데이트됨)
|
||||
if (chapter.completed) return "completed";
|
||||
|
||||
const anyStarted = chapter.lessons.some((lesson) => this._isLessonStarted(lesson));
|
||||
if (anyStarted) return "current";
|
||||
|
||||
// 챕터 자체가 활성화되어 있는지 확인
|
||||
const isActive = this._isChapterActive(chapter);
|
||||
if (isActive) return "current";
|
||||
|
||||
return "base";
|
||||
}
|
||||
|
||||
/**
|
||||
* 학습 시작 여부 확인 (완료 또는 1초 이상 시청)
|
||||
* @private
|
||||
*/
|
||||
_isLessonStarted(lesson) {
|
||||
if (!lesson) return false;
|
||||
if (lesson.completed === true) return true;
|
||||
|
||||
const watchTm = Number.parseInt(lesson.watch_tm ?? 0, 10) || 0;
|
||||
return watchTm >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 챕터 활성화 여부 확인
|
||||
* @private
|
||||
*/
|
||||
_isChapterActive(chapter) {
|
||||
const allMarkers = this.config.getAllMarkers();
|
||||
const chapterMarkerIndex = allMarkers.findIndex(
|
||||
(m) => m.pathPercent === chapter.pathPercent && m.isChapterMarker === true
|
||||
);
|
||||
|
||||
if (chapterMarkerIndex === -1) return false;
|
||||
if (chapterMarkerIndex === 0) return true;
|
||||
return allMarkers[chapterMarkerIndex - 1].completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이 버튼 이미지 경로 반환
|
||||
* @private
|
||||
*/
|
||||
_getPlayButtonImagePath(state) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "/img/learning/btn_play_completed.png";
|
||||
case "current":
|
||||
return "/img/learning/btn_play_current.png";
|
||||
default:
|
||||
return "/img/learning/btn_play_base.png";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 챕터 진행률 계산
|
||||
* @private
|
||||
* @param {Object} chapter - 챕터 객체
|
||||
* @returns {number} 진행률 (0-100)
|
||||
*/
|
||||
_calculateChapterProgress(chapter) {
|
||||
const completedCount = chapter.lessons.filter(
|
||||
(lesson) => lesson.completed
|
||||
).length;
|
||||
const totalCount = chapter.lessons.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
console.log(
|
||||
`[ChapterCardManager] 챕터 "${chapter.name}" 진행률: ${completedCount}/${totalCount} (${progressPercent}%)`
|
||||
);
|
||||
|
||||
return progressPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 위치 설정
|
||||
* @private
|
||||
*/
|
||||
_positionCard(li, chapterLesson) {
|
||||
try {
|
||||
const gaugeSvg = this.gaugeManager.gaugeSvg || document.getElementById("gauge-svg") || document.getElementById("gauge-svg-mo");
|
||||
if (!gaugeSvg) {
|
||||
console.warn('[ChapterCardManager] gauge-svg 요소를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const viewBox = gaugeSvg.viewBox.baseVal;
|
||||
if (!viewBox || !viewBox.width || !viewBox.height) {
|
||||
console.warn('[ChapterCardManager] SVG viewBox가 유효하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobile = this.gaugeManager.isMobile;
|
||||
const pathPercent = (isMobile && chapterLesson.pathPercentMo != null) ? chapterLesson.pathPercentMo : (chapterLesson.pathPercent || 0);
|
||||
const point = this.gaugeManager.getPointAtPercent(pathPercent);
|
||||
|
||||
if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') {
|
||||
console.warn('[ChapterCardManager] 유효하지 않은 포인트입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// hanmac_study 기준 보정값 적용
|
||||
const percentX = (point.x / viewBox.width) * 100 + 1.3;
|
||||
const percentY = (point.y / viewBox.height) * 100 - 3;
|
||||
|
||||
if (this.domUtils) {
|
||||
this.domUtils.setStyles(li, {
|
||||
position: "absolute",
|
||||
left: `${percentX}%`,
|
||||
top: `${percentY}%`,
|
||||
transform: "translate(-50%, -105%)",
|
||||
zIndex: "10"
|
||||
});
|
||||
} else {
|
||||
li.style.position = "absolute";
|
||||
li.style.left = `${percentX}%`;
|
||||
li.style.top = `${percentY}%`;
|
||||
li.style.transform = "translate(-50%, -105%)";
|
||||
li.style.zIndex = "10";
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ChapterCardManager] 카드 위치: (${percentX.toFixed(2)}%, ${percentY.toFixed(2)}%)`
|
||||
);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager._positionCard', { chapterLesson });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 챕터 카드 상태 업데이트
|
||||
* @param {boolean} forceUpdate - 강제 업데이트 여부
|
||||
*/
|
||||
updateChapterCards(forceUpdate = false) {
|
||||
try {
|
||||
this.chapterCards.forEach((card, index) => {
|
||||
try {
|
||||
const chapter = card.chapter;
|
||||
const newState = this._getChapterState(chapter);
|
||||
const newProgress = this._calculateChapterProgress(chapter);
|
||||
|
||||
const gaugeFill = this.domUtils?.$(".card-gauge-fill", card.element) || card.element.querySelector(".card-gauge-fill");
|
||||
const currentProgress = gaugeFill
|
||||
? parseInt(gaugeFill.style.width) || 0
|
||||
: 0;
|
||||
|
||||
const shouldUpdate =
|
||||
forceUpdate ||
|
||||
card.state !== newState ||
|
||||
currentProgress !== newProgress;
|
||||
|
||||
if (shouldUpdate) {
|
||||
console.log(
|
||||
`[ChapterCardManager] 챕터 ${index + 1} ${forceUpdate ? "강제 " : ""}업데이트`
|
||||
);
|
||||
|
||||
// 클래스 업데이트
|
||||
card.element.className = "chapter-card";
|
||||
if (newState) {
|
||||
if (this.domUtils) {
|
||||
this.domUtils.addClasses(card.element, newState);
|
||||
} else {
|
||||
card.element.classList.add(newState);
|
||||
}
|
||||
}
|
||||
|
||||
// 플레이 버튼 업데이트
|
||||
const playButton = this.domUtils?.$(".card-play-button", card.element) || card.element.querySelector(".card-play-button");
|
||||
if (playButton) {
|
||||
playButton.src = this._getPlayButtonImagePath(newState);
|
||||
}
|
||||
|
||||
// 게이지바 업데이트 (애니메이션 적용 가능)
|
||||
if (gaugeFill) {
|
||||
if (this.animationUtils) {
|
||||
this.animationUtils.progressBar(gaugeFill, newProgress, 300);
|
||||
} else {
|
||||
gaugeFill.style.width = newProgress + "%";
|
||||
}
|
||||
}
|
||||
|
||||
card.state = newState;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager.updateChapterCards.card', { index });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager.updateChapterCards');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼 메서드
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.handle(error, {
|
||||
context,
|
||||
...additionalInfo,
|
||||
component: 'ChapterCardManager'
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[ChapterCardManager] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리 (이벤트 리스너 제거)
|
||||
*/
|
||||
destroy() {
|
||||
try {
|
||||
// 이벤트 리스너 제거
|
||||
if (this.eventManager) {
|
||||
this.listenerIds.forEach(({ element, id }) => {
|
||||
this.eventManager.off(element, id);
|
||||
});
|
||||
this.listenerIds = [];
|
||||
}
|
||||
|
||||
// 카드 배열 초기화
|
||||
this.chapterCards = [];
|
||||
this.cardsContainer = null;
|
||||
this.modalInstance = null;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'ChapterCardManager.destroy');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
/**
|
||||
* 학습 경로 설정
|
||||
* 공통 모듈 활용 (ErrorHandler, Utils, ConfigManager)
|
||||
*/
|
||||
const LEARNING_CONFIG = {
|
||||
// 마커 설정 - 챕터별로 그룹화
|
||||
chapters: [
|
||||
{
|
||||
id: 1,
|
||||
code: "CA200C01",
|
||||
name: "개인정보보호",
|
||||
type: "chapter",
|
||||
pathPercent: 0.108,
|
||||
pathPercentMo: 0.108, // PC와 동일 순서
|
||||
gaugePercent: 0.108,
|
||||
gaugePercentMo: 0.108,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false, // 하위 lessons가 모두 완료되면 자동으로 true
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.137,
|
||||
pathPercentMo: 0.137,
|
||||
gaugePercent: 0.137,
|
||||
gaugePercentMo: 0.137,
|
||||
type: "normal",
|
||||
label: "개인정보보호 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.159,
|
||||
pathPercentMo: 0.159,
|
||||
gaugePercent: 0.156,
|
||||
gaugePercentMo: 0.156,
|
||||
type: "normal",
|
||||
label: "개인정보보호 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.182,
|
||||
pathPercentMo: 0.182,
|
||||
gaugePercent: 0.178,
|
||||
gaugePercentMo: 0.178,
|
||||
type: "normal",
|
||||
label: "개인정보보호 3",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.205,
|
||||
pathPercentMo: 0.205,
|
||||
gaugePercent: 0.202,
|
||||
gaugePercentMo: 0.202,
|
||||
type: "normal",
|
||||
label: "개인정보보호 4",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.228,
|
||||
pathPercentMo: 0.228,
|
||||
gaugePercent: 0.226,
|
||||
gaugePercentMo: 0.226,
|
||||
type: "normal",
|
||||
label: "개인정보보호 5",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.25,
|
||||
pathPercentMo: 0.25,
|
||||
gaugePercent: 0.246,
|
||||
gaugePercentMo: 0.246,
|
||||
type: "normal",
|
||||
label: "개인정보보호 6",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.272,
|
||||
pathPercentMo: 0.272,
|
||||
gaugePercent: 0.268,
|
||||
gaugePercentMo: 0.268,
|
||||
type: "normal",
|
||||
label: "개인정보보호 7",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.298,
|
||||
pathPercentMo: 0.298,
|
||||
gaugePercent: 0.296,
|
||||
gaugePercentMo: 0.296,
|
||||
type: "normal",
|
||||
label: "개인정보보호 8",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: "CA200C02",
|
||||
name: "직장내 괴롭힘 예방",
|
||||
type: "chapter",
|
||||
pathPercent: 0.325,
|
||||
pathPercentMo: 0.325,
|
||||
gaugePercent: 0.325,
|
||||
gaugePercentMo: 0.325,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.367,
|
||||
pathPercentMo: 0.367,
|
||||
gaugePercent: 0.358,
|
||||
gaugePercentMo: 0.358,
|
||||
type: "normal",
|
||||
label: "직장내 괴롭힘 예방 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.41,
|
||||
pathPercentMo: 0.41,
|
||||
gaugePercent: 0.40,
|
||||
gaugePercentMo: 0.40,
|
||||
type: "normal",
|
||||
label: "직장내 괴롭힘 예방 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: "CA200C03",
|
||||
name: "성희롱 예방 교육",
|
||||
type: "chapter",
|
||||
pathPercent: 0.442,
|
||||
pathPercentMo: 0.442,
|
||||
gaugePercent: 0.442,
|
||||
gaugePercentMo: 0.442,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.486,
|
||||
pathPercentMo: 0.486,
|
||||
gaugePercent: 0.476,
|
||||
gaugePercentMo: 0.476,
|
||||
type: "normal",
|
||||
label: "성희롱 예방 교육 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.531,
|
||||
pathPercentMo: 0.531,
|
||||
gaugePercent: 0.526,
|
||||
gaugePercentMo: 0.526,
|
||||
type: "normal",
|
||||
label: "성희롱 예방 교육 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
code: "CA200C04",
|
||||
name: "산업안전 보건",
|
||||
type: "chapter",
|
||||
pathPercent: 0.555,
|
||||
pathPercentMo: 0.555,
|
||||
gaugePercent: 0.555,
|
||||
gaugePercentMo: 0.555,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.585,
|
||||
pathPercentMo: 0.585,
|
||||
gaugePercent: 0.582,
|
||||
gaugePercentMo: 0.582,
|
||||
type: "normal",
|
||||
label: "산업안전 보건 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.635,
|
||||
pathPercentMo: 0.635,
|
||||
gaugePercent: 0.632,
|
||||
gaugePercentMo: 0.632,
|
||||
type: "normal",
|
||||
label: "산업안전 보건 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
code: "CA200C05",
|
||||
name: "장애인 인식 개선",
|
||||
type: "chapter",
|
||||
pathPercent: 0.67,
|
||||
pathPercentMo: 0.67,
|
||||
gaugePercent: 0.67,
|
||||
gaugePercentMo: 0.67,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.69,
|
||||
pathPercentMo: 0.69,
|
||||
gaugePercent: 0.686,
|
||||
gaugePercentMo: 0.686,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.718,
|
||||
pathPercentMo: 0.718,
|
||||
gaugePercent: 0.71,
|
||||
gaugePercentMo: 0.71,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.736,
|
||||
pathPercentMo: 0.736,
|
||||
gaugePercent: 0.728,
|
||||
gaugePercentMo: 0.728,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 3",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.785,
|
||||
pathPercentMo: 0.785,
|
||||
gaugePercent: 0.778,
|
||||
gaugePercentMo: 0.778,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 4",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.82,
|
||||
pathPercentMo: 0.82,
|
||||
gaugePercent: 0.812,
|
||||
gaugePercentMo: 0.812,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 5",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.86,
|
||||
pathPercentMo: 0.86,
|
||||
gaugePercent: 0.85,
|
||||
gaugePercentMo: 0.85,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 6",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// 평균 학습량 설정 (전체 학습 항목 대비 %)
|
||||
averageProgress: {
|
||||
threshold: 60, // 평균 학습량: 전체의 60%
|
||||
},
|
||||
|
||||
// 마커 이미지 경로
|
||||
markerImages: {
|
||||
normal: {
|
||||
base: "/img/learning/mark_base.png",
|
||||
current: "/img/learning/mark_current.png",
|
||||
completed: "/img/learning/mark_completed.png",
|
||||
},
|
||||
chapter: {
|
||||
base: "/img/learning/mark_chapter_base.png",
|
||||
current: "/img/learning/mark_chapter_current.png",
|
||||
completed: "/img/learning/mark_chapter_completed.png",
|
||||
},
|
||||
},
|
||||
|
||||
// 상태 이미지 경로
|
||||
stateImages: {
|
||||
below: "/img/learning/img_state_01.svg", // 평균 이하
|
||||
average: "/img/learning/img_state_02.svg", // 평균
|
||||
above: "/img/learning/img_state_03.svg", // 평균 이상
|
||||
},
|
||||
|
||||
// 모달 경로
|
||||
modalPath: "./_modal/video-learning.php",
|
||||
|
||||
// 비활성 마커 클릭 설정
|
||||
settings: {
|
||||
allowDisabledClick: true, // true: 비활성 마커도 클릭 가능, false: 비활성 마커 클릭 불가
|
||||
disabledClickMessage: "이전 학습을 먼저 완료해주세요.", // 비활성 마커 클릭 시 메시지
|
||||
showDisabledAlert: false, // true: 알림 표시, false: 콘솔 로그만
|
||||
showStateIndicator: {
|
||||
pc: true, // PC에서 상태 인디케이터 표시
|
||||
mo: false, // 모바일에서도 상태 인디케이터 표시 (PC와 동일)
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* 챕터의 완료 상태 자동 업데이트
|
||||
* 하위 lessons가 모두 완료되면 챕터도 completed = true로 변경
|
||||
*/
|
||||
updateChapterCompletionStatus() {
|
||||
try {
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'updateChapterCompletionStatus');
|
||||
return;
|
||||
}
|
||||
|
||||
this.chapters.forEach((chapter, index) => {
|
||||
try {
|
||||
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allLessonsCompleted = chapter.lessons.every(
|
||||
(lesson) => lesson && lesson.completed === true
|
||||
);
|
||||
chapter.completed = allLessonsCompleted;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'updateChapterCompletionStatus.chapter', { chapterIndex: index });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'updateChapterCompletionStatus');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 전체 마커 배열 반환 (챕터 + lessons flat)
|
||||
* @returns {Array}
|
||||
*/
|
||||
getAllMarkers() {
|
||||
try {
|
||||
// 챕터 완료 상태 업데이트
|
||||
this.updateChapterCompletionStatus();
|
||||
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'getAllMarkers');
|
||||
return [];
|
||||
}
|
||||
|
||||
const markers = [];
|
||||
this.chapters.forEach((chapter, chapterIndex) => {
|
||||
try {
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 null입니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 챕터 자체를 마커로 추가 (시작점 표시용, 클릭 불가)
|
||||
markers.push({
|
||||
pathPercent: chapter.pathPercent || 0,
|
||||
pathPercentMo: chapter.pathPercentMo,
|
||||
gaugePercent: chapter.gaugePercent !== undefined ? chapter.gaugePercent : (chapter.pathPercent || 0),
|
||||
gaugePercentMo: chapter.gaugePercentMo,
|
||||
type: chapter.type || 'chapter',
|
||||
label: chapter.name || `챕터 ${chapterIndex + 1}`,
|
||||
url: chapter.url || '',
|
||||
completed: chapter.completed === true,
|
||||
chapterId: chapter.id || chapterIndex + 1,
|
||||
isChapterMarker: true,
|
||||
isLearningContent: false, // 강의 아님
|
||||
isClickable: false, // 클릭 불가
|
||||
});
|
||||
|
||||
// 하위 lessons 추가 (실제 강의)
|
||||
if (chapter.lessons && Array.isArray(chapter.lessons)) {
|
||||
chapter.lessons.forEach((lesson, lessonIndex) => {
|
||||
try {
|
||||
if (!lesson) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 레슨 ${lessonIndex}가 null입니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
markers.push({
|
||||
pathPercent: lesson.pathPercent || 0,
|
||||
pathPercentMo: lesson.pathPercentMo,
|
||||
gaugePercent: lesson.gaugePercent !== undefined ? lesson.gaugePercent : (lesson.pathPercent || 0),
|
||||
gaugePercentMo: lesson.gaugePercentMo,
|
||||
type: lesson.type || 'normal',
|
||||
label: lesson.label || `레슨 ${lessonIndex + 1}`,
|
||||
url: lesson.url || '',
|
||||
content_id: lesson.content_id || '',
|
||||
watch_tm: Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0),
|
||||
content_tm: Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0),
|
||||
all_tm: Math.max(0, Number.parseInt(lesson.all_tm ?? 0, 10) || 0),
|
||||
completed: lesson.completed === true,
|
||||
chapterId: chapter.id || chapterIndex + 1,
|
||||
isChapterMarker: false,
|
||||
isLearningContent: true, // 실제 강의
|
||||
isClickable: true, // 클릭 가능
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getAllMarkers.lesson', { chapterIndex, lessonIndex });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getAllMarkers.chapter', { chapterIndex });
|
||||
}
|
||||
});
|
||||
|
||||
return markers;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getAllMarkers');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 인덱스의 챕터 정보 반환
|
||||
* @param {number} globalIndex - 전체 마커 기준 인덱스
|
||||
* @returns {Object|null} { chapterIndex, chapterData, lessonIndex, lessonData, isChapterMarker }
|
||||
*/
|
||||
getChapterByGlobalIndex(globalIndex) {
|
||||
try {
|
||||
if (typeof globalIndex !== 'number' || globalIndex < 0) {
|
||||
this._handleError(new Error(`유효하지 않은 globalIndex: ${globalIndex}`), 'getChapterByGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
const allMarkers = this.getAllMarkers();
|
||||
|
||||
if (!Array.isArray(allMarkers) || globalIndex >= allMarkers.length) {
|
||||
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}가 범위를 벗어났습니다. (총 ${allMarkers.length}개)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const marker = allMarkers[globalIndex];
|
||||
if (!marker) {
|
||||
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}의 마커를 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const chapterIndex = this.chapters.findIndex(
|
||||
(ch) => ch && ch.id === marker.chapterId
|
||||
);
|
||||
|
||||
if (chapterIndex === -1) {
|
||||
console.warn(`[LEARNING_CONFIG] chapterId ${marker.chapterId}에 해당하는 챕터를 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const chapter = this.chapters[chapterIndex];
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${chapterIndex}의 데이터가 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (marker.isChapterMarker) {
|
||||
// 챕터 마커인 경우
|
||||
return {
|
||||
chapterIndex,
|
||||
chapterData: chapter,
|
||||
lessonIndex: -1, // 챕터 자체이므로 -1
|
||||
lessonData: marker,
|
||||
isChapterMarker: true,
|
||||
};
|
||||
} else {
|
||||
// 일반 레슨인 경우
|
||||
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const lessonIndex = chapter.lessons.findIndex(
|
||||
(lesson) => lesson && lesson.pathPercent === marker.pathPercent
|
||||
);
|
||||
|
||||
if (lessonIndex === -1) {
|
||||
console.warn(`[LEARNING_CONFIG] pathPercent ${marker.pathPercent}에 해당하는 레슨을 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chapterIndex,
|
||||
chapterData: chapter,
|
||||
lessonIndex,
|
||||
lessonData: marker,
|
||||
isChapterMarker: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getChapterByGlobalIndex', { globalIndex });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 로컬 인덱스를 글로벌 인덱스로 변환
|
||||
* @param {number} chapterIndex - 챕터 인덱스
|
||||
* @param {number} lessonIndex - 챕터 내 학습 인덱스 (-1이면 챕터 자체)
|
||||
* @returns {number|null}
|
||||
*/
|
||||
toGlobalIndex(chapterIndex, lessonIndex) {
|
||||
try {
|
||||
if (typeof chapterIndex !== 'number' || chapterIndex < 0) {
|
||||
this._handleError(new Error(`유효하지 않은 chapterIndex: ${chapterIndex}`), 'toGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof lessonIndex !== 'number') {
|
||||
this._handleError(new Error(`유효하지 않은 lessonIndex: ${lessonIndex}`), 'toGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'toGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chapterIndex >= this.chapters.length) {
|
||||
console.warn(`[LEARNING_CONFIG] chapterIndex ${chapterIndex}가 범위를 벗어났습니다. (총 ${this.chapters.length}개)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
for (let i = 0; i < chapterIndex; i++) {
|
||||
const chapter = this.chapters[i];
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${i}가 null입니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
globalIndex += 1; // 챕터 마커
|
||||
|
||||
if (chapter.lessons && Array.isArray(chapter.lessons)) {
|
||||
globalIndex += chapter.lessons.length; // 하위 lessons
|
||||
}
|
||||
}
|
||||
|
||||
if (lessonIndex === -1) {
|
||||
// 챕터 마커 자체
|
||||
return globalIndex;
|
||||
} else {
|
||||
// 하위 lesson
|
||||
const chapter = this.chapters[chapterIndex];
|
||||
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lessonIndex >= chapter.lessons.length) {
|
||||
console.warn(`[LEARNING_CONFIG] lessonIndex ${lessonIndex}가 범위를 벗어났습니다. (총 ${chapter.lessons.length}개)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return globalIndex + 1 + lessonIndex;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'toGlobalIndex', { chapterIndex, lessonIndex });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: `LEARNING_CONFIG.${context}`,
|
||||
component: 'LEARNING_CONFIG',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[LEARNING_CONFIG] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 설정 유효성 검증
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate() {
|
||||
try {
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'validate');
|
||||
return false;
|
||||
}
|
||||
|
||||
let isValid = true;
|
||||
this.chapters.forEach((chapter, index) => {
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}가 null입니다.`);
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chapter.id) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 id가 없습니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!chapter.name) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 name이 없습니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
|
||||
isValid = false;
|
||||
} else {
|
||||
chapter.lessons.forEach((lesson, lessonIndex) => {
|
||||
if (!lesson) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}가 null입니다.`);
|
||||
isValid = false;
|
||||
} else {
|
||||
if (!lesson.label) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 label이 없습니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
if (typeof lesson.pathPercent !== 'number') {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 pathPercent가 없거나 숫자가 아닙니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'validate');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HTML에서 내려준 learningChapterData를 코드 기준으로 반영
|
||||
* - category_group(code) 기준 챕터 매핑
|
||||
* - lesson title/url/completed 동기화
|
||||
*/
|
||||
if (typeof window !== "undefined" && Array.isArray(window.learningChapterData)) {
|
||||
try {
|
||||
const serverChapters = window.learningChapterData;
|
||||
const chapterByCode = new Map(
|
||||
serverChapters
|
||||
.filter((ch) => ch && typeof ch === 'object' && ch.code)
|
||||
.map((ch) => [String(ch.code), ch])
|
||||
);
|
||||
|
||||
LEARNING_CONFIG.chapters.forEach((chapter, chapterIndex) => {
|
||||
const serverChapter = chapterByCode.get(String(chapter.code || ''))
|
||||
|| serverChapters.find((ch) => Number(ch?.id) === Number(chapter.id));
|
||||
|
||||
if (!serverChapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverChapter.name) {
|
||||
chapter.name = String(serverChapter.name);
|
||||
}
|
||||
|
||||
const serverLessons = Array.isArray(serverChapter.lessons) ? serverChapter.lessons : [];
|
||||
if (serverLessons.length === 0) {
|
||||
chapter.completed = false;
|
||||
chapter.lessons.forEach((lesson) => {
|
||||
lesson.completed = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const applyCount = Math.min(chapter.lessons.length, serverLessons.length);
|
||||
for (let i = 0; i < applyCount; i++) {
|
||||
const localLesson = chapter.lessons[i];
|
||||
const serverLesson = serverLessons[i] || {};
|
||||
|
||||
if (serverLesson.title) {
|
||||
localLesson.label = String(serverLesson.title);
|
||||
}
|
||||
if (serverLesson.url) {
|
||||
localLesson.url = String(serverLesson.url);
|
||||
}
|
||||
if (serverLesson.content_id) {
|
||||
localLesson.content_id = String(serverLesson.content_id);
|
||||
}
|
||||
if (serverLesson.watch_tm !== undefined) {
|
||||
localLesson.watch_tm = Math.max(0, Number.parseInt(serverLesson.watch_tm, 10) || 0);
|
||||
}
|
||||
if (serverLesson.content_tm !== undefined) {
|
||||
localLesson.content_tm = Math.max(0, Number.parseInt(serverLesson.content_tm, 10) || 0);
|
||||
}
|
||||
if (serverLesson.all_tm !== undefined) {
|
||||
localLesson.all_tm = Math.max(0, Number.parseInt(serverLesson.all_tm, 10) || 0);
|
||||
}
|
||||
if (serverLesson.completed !== undefined) {
|
||||
localLesson.completed = serverLesson.completed === true;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = applyCount; i < chapter.lessons.length; i++) {
|
||||
chapter.lessons[i].completed = false;
|
||||
}
|
||||
|
||||
chapter.completed = chapter.lessons.length > 0
|
||||
&& chapter.lessons.every((lesson) => lesson && lesson.completed === true);
|
||||
|
||||
if (serverLessons.length !== chapter.lessons.length) {
|
||||
console.warn(
|
||||
`[LEARNING_CONFIG] 챕터 ${chapterIndex + 1}(${chapter.code}) 차시 수 불일치: local=${chapter.lessons.length}, server=${serverLessons.length}`
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: 'LEARNING_CONFIG.applyLearningChapterData'
|
||||
}, false);
|
||||
} else {
|
||||
console.error('[LEARNING_CONFIG] learningChapterData 적용 에러:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 설정된 learningConfigData를 LEARNING_CONFIG에 적용
|
||||
* window.learningConfigData가 있으면 completed 상태를 업데이트
|
||||
*/
|
||||
if (typeof window !== "undefined" && window.learningConfigData) {
|
||||
try {
|
||||
const configData = window.learningConfigData;
|
||||
|
||||
if (!configData || typeof configData !== 'object') {
|
||||
console.warn('[LEARNING_CONFIG] learningConfigData가 유효하지 않습니다.');
|
||||
} else {
|
||||
LEARNING_CONFIG.chapters.forEach((chapter, chapterIndex) => {
|
||||
try {
|
||||
if (!chapter || !chapter.id) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 유효하지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chapterData = configData[chapter.code] ?? configData[chapter.id];
|
||||
|
||||
if (chapterData) {
|
||||
// 챕터 완료 상태 업데이트
|
||||
if (chapterData.completed !== undefined) {
|
||||
chapter.completed = chapterData.completed === true;
|
||||
}
|
||||
|
||||
// 레슨 완료 상태 업데이트
|
||||
if (chapterData.lessons && Array.isArray(chapterData.lessons)) {
|
||||
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
chapterData.lessons.forEach((lessonData, index) => {
|
||||
try {
|
||||
if (chapter.lessons[index] && lessonData && lessonData.completed !== undefined) {
|
||||
chapter.lessons[index].completed = lessonData.completed === true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[LEARNING_CONFIG] 레슨 ${index} 업데이트 에러:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: 'LEARNING_CONFIG.loadFromHTML.chapter',
|
||||
chapterIndex
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[LEARNING_CONFIG] 챕터 ${chapterIndex} 업데이트 에러:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[LEARNING_CONFIG] learningConfigData 적용 완료:",
|
||||
LEARNING_CONFIG
|
||||
);
|
||||
|
||||
// 설정 유효성 검증
|
||||
if (LEARNING_CONFIG.validate) {
|
||||
const isValid = LEARNING_CONFIG.validate();
|
||||
if (!isValid) {
|
||||
console.warn('[LEARNING_CONFIG] 설정 유효성 검증 실패');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: 'LEARNING_CONFIG.loadFromHTML'
|
||||
}, false);
|
||||
} else {
|
||||
console.error('[LEARNING_CONFIG] learningConfigData 적용 에러:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
/**
|
||||
* 학습 경로 설정
|
||||
* 공통 모듈 활용 (ErrorHandler, Utils, ConfigManager)
|
||||
*/
|
||||
const LEARNING_CONFIG = {
|
||||
// 마커 설정 - 챕터별로 그룹화
|
||||
chapters: [
|
||||
{
|
||||
id: 1,
|
||||
code: "CA200C01",
|
||||
name: "개인정보보호",
|
||||
type: "chapter",
|
||||
pathPercent: 0.108,
|
||||
pathPercentMo: 0.108, // PC와 동일 순서
|
||||
gaugePercent: 0.108,
|
||||
gaugePercentMo: 0.108,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false, // 하위 lessons가 모두 완료되면 자동으로 true
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.137,
|
||||
pathPercentMo: 0.137,
|
||||
gaugePercent: 0.137,
|
||||
gaugePercentMo: 0.137,
|
||||
type: "normal",
|
||||
label: "개인정보보호 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.159,
|
||||
pathPercentMo: 0.159,
|
||||
gaugePercent: 0.156,
|
||||
gaugePercentMo: 0.156,
|
||||
type: "normal",
|
||||
label: "개인정보보호 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.182,
|
||||
pathPercentMo: 0.182,
|
||||
gaugePercent: 0.178,
|
||||
gaugePercentMo: 0.178,
|
||||
type: "normal",
|
||||
label: "개인정보보호 3",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
// {
|
||||
// pathPercent: 0.205,
|
||||
// pathPercentMo: 0.205,
|
||||
// gaugePercent: 0.202,
|
||||
// gaugePercentMo: 0.202,
|
||||
// type: "normal",
|
||||
// label: "개인정보보호 4",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
// {
|
||||
// pathPercent: 0.228,
|
||||
// pathPercentMo: 0.228,
|
||||
// gaugePercent: 0.226,
|
||||
// gaugePercentMo: 0.226,
|
||||
// type: "normal",
|
||||
// label: "개인정보보호 5",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
// {
|
||||
// pathPercent: 0.25,
|
||||
// pathPercentMo: 0.25,
|
||||
// gaugePercent: 0.246,
|
||||
// gaugePercentMo: 0.246,
|
||||
// type: "normal",
|
||||
// label: "개인정보보호 6",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
// {
|
||||
// pathPercent: 0.272,
|
||||
// pathPercentMo: 0.272,
|
||||
// gaugePercent: 0.268,
|
||||
// gaugePercentMo: 0.268,
|
||||
// type: "normal",
|
||||
// label: "개인정보보호 7",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
// {
|
||||
// pathPercent: 0.298,
|
||||
// pathPercentMo: 0.298,
|
||||
// gaugePercent: 0.296,
|
||||
// gaugePercentMo: 0.296,
|
||||
// type: "normal",
|
||||
// label: "개인정보보호 8",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: "CA200C02",
|
||||
name: "직장내 괴롭힘 예방",
|
||||
type: "chapter",
|
||||
pathPercent: 0.325,
|
||||
pathPercentMo: 0.325,
|
||||
gaugePercent: 0.325,
|
||||
gaugePercentMo: 0.325,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.367,
|
||||
pathPercentMo: 0.367,
|
||||
gaugePercent: 0.358,
|
||||
gaugePercentMo: 0.358,
|
||||
type: "normal",
|
||||
label: "직장내 괴롭힘 예방 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.41,
|
||||
pathPercentMo: 0.41,
|
||||
gaugePercent: 0.40,
|
||||
gaugePercentMo: 0.40,
|
||||
type: "normal",
|
||||
label: "직장내 괴롭힘 예방 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: "CA200C03",
|
||||
name: "장애인 인식 개선",
|
||||
type: "chapter",
|
||||
pathPercent: 0.442,
|
||||
pathPercentMo: 0.442,
|
||||
gaugePercent: 0.442,
|
||||
gaugePercentMo: 0.442,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.486,
|
||||
pathPercentMo: 0.486,
|
||||
gaugePercent: 0.476,
|
||||
gaugePercentMo: 0.476,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.531,
|
||||
pathPercentMo: 0.531,
|
||||
gaugePercent: 0.526,
|
||||
gaugePercentMo: 0.526,
|
||||
type: "normal",
|
||||
label: "장애인 인식 개선 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
code: "CA200C04",
|
||||
name: "성희롱 예방 교육",
|
||||
type: "chapter",
|
||||
pathPercent: 0.555,
|
||||
pathPercentMo: 0.555,
|
||||
gaugePercent: 0.555,
|
||||
gaugePercentMo: 0.555,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.585,
|
||||
pathPercentMo: 0.585,
|
||||
gaugePercent: 0.582,
|
||||
gaugePercentMo: 0.582,
|
||||
type: "normal",
|
||||
label: "성희롱 예방 교육 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.635,
|
||||
pathPercentMo: 0.635,
|
||||
gaugePercent: 0.632,
|
||||
gaugePercentMo: 0.632,
|
||||
type: "normal",
|
||||
label: "성희롱 예방 교육 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
code: "CA200C05",
|
||||
name: "산업안전 보건",
|
||||
type: "chapter",
|
||||
pathPercent: 0.67,
|
||||
pathPercentMo: 0.67,
|
||||
gaugePercent: 0.67,
|
||||
gaugePercentMo: 0.67,
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
lessons: [
|
||||
{
|
||||
pathPercent: 0.69,
|
||||
pathPercentMo: 0.69,
|
||||
gaugePercent: 0.686,
|
||||
gaugePercentMo: 0.686,
|
||||
type: "normal",
|
||||
label: "퇴직금 교육 1",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.718,
|
||||
pathPercentMo: 0.718,
|
||||
gaugePercent: 0.71,
|
||||
gaugePercentMo: 0.71,
|
||||
type: "normal",
|
||||
label: "퇴직금 교육 2",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
pathPercent: 0.736,
|
||||
pathPercentMo: 0.736,
|
||||
gaugePercent: 0.728,
|
||||
gaugePercentMo: 0.728,
|
||||
type: "normal",
|
||||
label: "퇴직금 교육 3",
|
||||
url: "ddILV5cbdQo",
|
||||
completed: false,
|
||||
},
|
||||
// {
|
||||
// pathPercent: 0.785,
|
||||
// pathPercentMo: 0.785,
|
||||
// gaugePercent: 0.778,
|
||||
// gaugePercentMo: 0.778,
|
||||
// type: "normal",
|
||||
// label: "장애인 인식 개선 4",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
// {
|
||||
// pathPercent: 0.82,
|
||||
// pathPercentMo: 0.82,
|
||||
// gaugePercent: 0.812,
|
||||
// gaugePercentMo: 0.812,
|
||||
// type: "normal",
|
||||
// label: "장애인 인식 개선 5",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
// {
|
||||
// pathPercent: 0.86,
|
||||
// pathPercentMo: 0.86,
|
||||
// gaugePercent: 0.85,
|
||||
// gaugePercentMo: 0.85,
|
||||
// type: "normal",
|
||||
// label: "장애인 인식 개선 6",
|
||||
// url: "ddILV5cbdQo",
|
||||
// completed: false,
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
// 평균 학습량 설정 (전체 학습 항목 대비 %)
|
||||
averageProgress: {
|
||||
threshold: 60, // 평균 학습량: 전체의 60%
|
||||
},
|
||||
|
||||
// 마커 이미지 경로
|
||||
markerImages: {
|
||||
normal: {
|
||||
base: "/img/learning/mark_base.png",
|
||||
current: "/img/learning/mark_current.png",
|
||||
completed: "/img/learning/mark_completed.png",
|
||||
},
|
||||
chapter: {
|
||||
base: "/img/learning/mark_chapter_base.png",
|
||||
current: "/img/learning/mark_chapter_current.png",
|
||||
completed: "/img/learning/mark_chapter_completed.png",
|
||||
},
|
||||
},
|
||||
|
||||
// 상태 이미지 경로
|
||||
stateImages: {
|
||||
below: "/img/learning/img_state_01.svg", // 평균 이하
|
||||
average: "/img/learning/img_state_02.svg", // 평균
|
||||
above: "/img/learning/img_state_03.svg", // 평균 이상
|
||||
},
|
||||
|
||||
// 모달 경로
|
||||
modalPath: "./_modal/video-learning.php",
|
||||
|
||||
// 비활성 마커 클릭 설정
|
||||
settings: {
|
||||
useMarkers: true, // false: 마커 UI 미사용
|
||||
useLessonMarkers: false, // false: 하위 레슨 마커 숨김 (챕터 마커만 표시)
|
||||
allowDisabledClick: true, // true: 비활성 마커도 클릭 가능, false: 비활성 마커 클릭 불가
|
||||
disabledClickMessage: "이전 학습을 먼저 완료해주세요.", // 비활성 마커 클릭 시 메시지
|
||||
showDisabledAlert: false, // true: 알림 표시, false: 콘솔 로그만
|
||||
showStateIndicator: {
|
||||
pc: false, // PC에서 상태 인디케이터 표시
|
||||
mo: false, // 모바일에서도 상태 인디케이터 표시 (PC와 동일)
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* 챕터의 완료 상태 자동 업데이트
|
||||
* 하위 lessons가 모두 완료되면 챕터도 completed = true로 변경
|
||||
*/
|
||||
updateChapterCompletionStatus() {
|
||||
try {
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'updateChapterCompletionStatus');
|
||||
return;
|
||||
}
|
||||
|
||||
this.chapters.forEach((chapter, index) => {
|
||||
try {
|
||||
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allLessonsCompleted = chapter.lessons.every(
|
||||
(lesson) => lesson && lesson.completed === true
|
||||
);
|
||||
chapter.completed = allLessonsCompleted;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'updateChapterCompletionStatus.chapter', { chapterIndex: index });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'updateChapterCompletionStatus');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 전체 마커 배열 반환 (챕터 + lessons flat)
|
||||
* @returns {Array}
|
||||
*/
|
||||
getAllMarkers() {
|
||||
try {
|
||||
// 챕터 완료 상태 업데이트
|
||||
this.updateChapterCompletionStatus();
|
||||
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'getAllMarkers');
|
||||
return [];
|
||||
}
|
||||
|
||||
const markers = [];
|
||||
this.chapters.forEach((chapter, chapterIndex) => {
|
||||
try {
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 null입니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 챕터 자체를 마커로 추가 (시작점 표시용, 클릭 불가)
|
||||
markers.push({
|
||||
pathPercent: chapter.pathPercent || 0,
|
||||
pathPercentMo: chapter.pathPercentMo,
|
||||
gaugePercent: chapter.gaugePercent !== undefined ? chapter.gaugePercent : (chapter.pathPercent || 0),
|
||||
gaugePercentMo: chapter.gaugePercentMo,
|
||||
type: chapter.type || 'chapter',
|
||||
label: chapter.name || `챕터 ${chapterIndex + 1}`,
|
||||
url: chapter.url || '',
|
||||
completed: chapter.completed === true,
|
||||
chapterId: chapter.id || chapterIndex + 1,
|
||||
isChapterMarker: true,
|
||||
isLearningContent: false, // 강의 아님
|
||||
isClickable: false, // 클릭 불가
|
||||
});
|
||||
|
||||
// 하위 lessons 추가 (실제 강의)
|
||||
if (chapter.lessons && Array.isArray(chapter.lessons)) {
|
||||
chapter.lessons.forEach((lesson, lessonIndex) => {
|
||||
try {
|
||||
if (!lesson) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 레슨 ${lessonIndex}가 null입니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
markers.push({
|
||||
pathPercent: lesson.pathPercent || 0,
|
||||
pathPercentMo: lesson.pathPercentMo,
|
||||
gaugePercent: lesson.gaugePercent !== undefined ? lesson.gaugePercent : (lesson.pathPercent || 0),
|
||||
gaugePercentMo: lesson.gaugePercentMo,
|
||||
type: lesson.type || 'normal',
|
||||
label: lesson.label || `레슨 ${lessonIndex + 1}`,
|
||||
url: lesson.url || '',
|
||||
content_id: lesson.content_id || '',
|
||||
watch_tm: Math.max(0, Number.parseInt(lesson.watch_tm ?? 0, 10) || 0),
|
||||
content_tm: Math.max(0, Number.parseInt(lesson.content_tm ?? 0, 10) || 0),
|
||||
all_tm: Math.max(0, Number.parseInt(lesson.all_tm ?? 0, 10) || 0),
|
||||
completed: lesson.completed === true,
|
||||
description: lesson.description || '',
|
||||
chapterId: chapter.id || chapterIndex + 1,
|
||||
isChapterMarker: false,
|
||||
isLearningContent: true, // 실제 강의
|
||||
isClickable: true, // 클릭 가능
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getAllMarkers.lesson', { chapterIndex, lessonIndex });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getAllMarkers.chapter', { chapterIndex });
|
||||
}
|
||||
});
|
||||
|
||||
return markers;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getAllMarkers');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 인덱스의 챕터 정보 반환
|
||||
* @param {number} globalIndex - 전체 마커 기준 인덱스
|
||||
* @returns {Object|null} { chapterIndex, chapterData, lessonIndex, lessonData, isChapterMarker }
|
||||
*/
|
||||
getChapterByGlobalIndex(globalIndex) {
|
||||
try {
|
||||
if (typeof globalIndex !== 'number' || globalIndex < 0) {
|
||||
this._handleError(new Error(`유효하지 않은 globalIndex: ${globalIndex}`), 'getChapterByGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
const allMarkers = this.getAllMarkers();
|
||||
|
||||
if (!Array.isArray(allMarkers) || globalIndex >= allMarkers.length) {
|
||||
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}가 범위를 벗어났습니다. (총 ${allMarkers.length}개)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const marker = allMarkers[globalIndex];
|
||||
if (!marker) {
|
||||
console.warn(`[LEARNING_CONFIG] globalIndex ${globalIndex}의 마커를 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const chapterIndex = this.chapters.findIndex(
|
||||
(ch) => ch && ch.id === marker.chapterId
|
||||
);
|
||||
|
||||
if (chapterIndex === -1) {
|
||||
console.warn(`[LEARNING_CONFIG] chapterId ${marker.chapterId}에 해당하는 챕터를 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const chapter = this.chapters[chapterIndex];
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${chapterIndex}의 데이터가 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (marker.isChapterMarker) {
|
||||
// 챕터 마커인 경우
|
||||
return {
|
||||
chapterIndex,
|
||||
chapterData: chapter,
|
||||
lessonIndex: -1, // 챕터 자체이므로 -1
|
||||
lessonData: marker,
|
||||
isChapterMarker: true,
|
||||
};
|
||||
} else {
|
||||
// 일반 레슨인 경우
|
||||
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const lessonIndex = chapter.lessons.findIndex(
|
||||
(lesson) => lesson && lesson.pathPercent === marker.pathPercent
|
||||
);
|
||||
|
||||
if (lessonIndex === -1) {
|
||||
console.warn(`[LEARNING_CONFIG] pathPercent ${marker.pathPercent}에 해당하는 레슨을 찾을 수 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chapterIndex,
|
||||
chapterData: chapter,
|
||||
lessonIndex,
|
||||
lessonData: marker,
|
||||
isChapterMarker: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getChapterByGlobalIndex', { globalIndex });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 로컬 인덱스를 글로벌 인덱스로 변환
|
||||
* @param {number} chapterIndex - 챕터 인덱스
|
||||
* @param {number} lessonIndex - 챕터 내 학습 인덱스 (-1이면 챕터 자체)
|
||||
* @returns {number|null}
|
||||
*/
|
||||
toGlobalIndex(chapterIndex, lessonIndex) {
|
||||
try {
|
||||
if (typeof chapterIndex !== 'number' || chapterIndex < 0) {
|
||||
this._handleError(new Error(`유효하지 않은 chapterIndex: ${chapterIndex}`), 'toGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof lessonIndex !== 'number') {
|
||||
this._handleError(new Error(`유효하지 않은 lessonIndex: ${lessonIndex}`), 'toGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'toGlobalIndex');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chapterIndex >= this.chapters.length) {
|
||||
console.warn(`[LEARNING_CONFIG] chapterIndex ${chapterIndex}가 범위를 벗어났습니다. (총 ${this.chapters.length}개)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
for (let i = 0; i < chapterIndex; i++) {
|
||||
const chapter = this.chapters[i];
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 인덱스 ${i}가 null입니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
globalIndex += 1; // 챕터 마커
|
||||
|
||||
if (chapter.lessons && Array.isArray(chapter.lessons)) {
|
||||
globalIndex += chapter.lessons.length; // 하위 lessons
|
||||
}
|
||||
}
|
||||
|
||||
if (lessonIndex === -1) {
|
||||
// 챕터 마커 자체
|
||||
return globalIndex;
|
||||
} else {
|
||||
// 하위 lesson
|
||||
const chapter = this.chapters[chapterIndex];
|
||||
if (!chapter || !chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lessonIndex >= chapter.lessons.length) {
|
||||
console.warn(`[LEARNING_CONFIG] lessonIndex ${lessonIndex}가 범위를 벗어났습니다. (총 ${chapter.lessons.length}개)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return globalIndex + 1 + lessonIndex;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'toGlobalIndex', { chapterIndex, lessonIndex });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: `LEARNING_CONFIG.${context}`,
|
||||
component: 'LEARNING_CONFIG',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[LEARNING_CONFIG] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 설정 유효성 검증
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate() {
|
||||
try {
|
||||
if (!this.chapters || !Array.isArray(this.chapters)) {
|
||||
this._handleError(new Error('chapters가 배열이 아닙니다.'), 'validate');
|
||||
return false;
|
||||
}
|
||||
|
||||
let isValid = true;
|
||||
this.chapters.forEach((chapter, index) => {
|
||||
if (!chapter) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}가 null입니다.`);
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chapter.id) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 id가 없습니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!chapter.name) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}에 name이 없습니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 lessons가 유효하지 않습니다.`);
|
||||
isValid = false;
|
||||
} else {
|
||||
chapter.lessons.forEach((lesson, lessonIndex) => {
|
||||
if (!lesson) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}가 null입니다.`);
|
||||
isValid = false;
|
||||
} else {
|
||||
if (!lesson.label) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 label이 없습니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
if (typeof lesson.pathPercent !== 'number') {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${index}의 레슨 ${lessonIndex}에 pathPercent가 없거나 숫자가 아닙니다.`);
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'validate');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* HTML에서 내려준 learningChapterData를 기반으로 chapters 동적 생성
|
||||
* - category_group(code) 기준 챕터 그룹핑
|
||||
* - DB 데이터가 있으면 하드코딩 대신 동적으로 chapters 배열을 재구성
|
||||
* - pathPercent/gaugePercent를 총 항목 수에 맞게 균등 분배
|
||||
*/
|
||||
if (typeof window !== "undefined" && Array.isArray(window.learningChapterData) && window.learningChapterData.length > 0) {
|
||||
try {
|
||||
const serverChapters = window.learningChapterData;
|
||||
|
||||
// ── pathPercent / gaugePercent 균등 분배 계산 ──
|
||||
const PATH_START = 0.10;
|
||||
const PATH_END = 0.88;
|
||||
|
||||
// 총 마커 수 계산 (각 챕터 1개 + 각 챕터의 lessons 수)
|
||||
const totalItems = serverChapters.reduce((sum, ch) => {
|
||||
const lessonCount = Array.isArray(ch.lessons) ? ch.lessons.length : 0;
|
||||
return sum + 1 + lessonCount; // 1 for chapter marker + lessons
|
||||
}, 0);
|
||||
|
||||
const step = totalItems > 1 ? (PATH_END - PATH_START) / (totalItems - 1) : 0;
|
||||
let positionIndex = 0;
|
||||
|
||||
// ── 서버 데이터로 chapters 동적 생성 ──
|
||||
const dynamicChapters = serverChapters.map((serverChapter, chapterIndex) => {
|
||||
// 챕터 위치는 하드코딩값 우선 유지 (DB 데이터가 있어도 챕터 카드 배치 고정)
|
||||
const hardcodedChapter = LEARNING_CONFIG.chapters.find((ch) =>
|
||||
(ch?.code && serverChapter?.code && ch.code === serverChapter.code) ||
|
||||
(ch?.id && serverChapter?.id && Number(ch.id) === Number(serverChapter.id))
|
||||
) || LEARNING_CONFIG.chapters[chapterIndex];
|
||||
|
||||
const chapterPercent =
|
||||
typeof hardcodedChapter?.pathPercent === "number"
|
||||
? hardcodedChapter.pathPercent
|
||||
: (PATH_START + (step * positionIndex));
|
||||
positionIndex++;
|
||||
|
||||
const serverLessons = Array.isArray(serverChapter.lessons) ? serverChapter.lessons : [];
|
||||
|
||||
const lessons = serverLessons.map((sl, lessonIndex) => {
|
||||
const lessonPercent = PATH_START + (step * positionIndex);
|
||||
positionIndex++;
|
||||
|
||||
return {
|
||||
pathPercent: Math.round(lessonPercent * 1000) / 1000,
|
||||
pathPercentMo: Math.round(lessonPercent * 1000) / 1000,
|
||||
gaugePercent: Math.round(lessonPercent * 1000) / 1000,
|
||||
gaugePercentMo: Math.round(lessonPercent * 1000) / 1000,
|
||||
type: "normal",
|
||||
label: String(sl.title || `차시 ${lessonIndex + 1}`),
|
||||
url: String(sl.url || ''),
|
||||
content_id: String(sl.content_id || ''),
|
||||
watch_tm: Math.max(0, Number.parseInt(sl.watch_tm ?? 0, 10) || 0),
|
||||
content_tm: Math.max(0, Number.parseInt(sl.content_tm ?? 0, 10) || 0),
|
||||
all_tm: Math.max(0, Number.parseInt(sl.all_tm ?? 0, 10) || 0),
|
||||
completed: sl.completed === true,
|
||||
description: String(sl.description || ''),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: chapterIndex + 1,
|
||||
code: String(serverChapter.code || ''),
|
||||
name: String(serverChapter.name || `챕터 ${chapterIndex + 1}`),
|
||||
type: "chapter",
|
||||
pathPercent: Math.round(chapterPercent * 1000) / 1000,
|
||||
pathPercentMo: typeof hardcodedChapter?.pathPercentMo === "number"
|
||||
? hardcodedChapter.pathPercentMo
|
||||
: Math.round(chapterPercent * 1000) / 1000,
|
||||
gaugePercent: typeof hardcodedChapter?.gaugePercent === "number"
|
||||
? hardcodedChapter.gaugePercent
|
||||
: Math.round(chapterPercent * 1000) / 1000,
|
||||
gaugePercentMo: typeof hardcodedChapter?.gaugePercentMo === "number"
|
||||
? hardcodedChapter.gaugePercentMo
|
||||
: Math.round(chapterPercent * 1000) / 1000,
|
||||
url: lessons.length > 0 ? lessons[0].url : '',
|
||||
completed: lessons.length > 0 && lessons.every((l) => l.completed === true),
|
||||
lessons: lessons,
|
||||
};
|
||||
});
|
||||
|
||||
// 하드코딩된 chapters를 서버 데이터로 교체
|
||||
LEARNING_CONFIG.chapters = dynamicChapters;
|
||||
console.log(
|
||||
`[LEARNING_CONFIG] DB 데이터에서 ${dynamicChapters.length}개 챕터, ` +
|
||||
`${dynamicChapters.reduce((s, ch) => s + ch.lessons.length, 0)}개 차시 동적 생성 완료`
|
||||
);
|
||||
} catch (error) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: 'LEARNING_CONFIG.applyLearningChapterData'
|
||||
}, false);
|
||||
} else {
|
||||
console.error('[LEARNING_CONFIG] learningChapterData 동적 생성 에러:', error);
|
||||
}
|
||||
// 에러 발생 시 하드코딩된 chapters 유지 (폴백)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML에서 설정된 learningConfigData를 LEARNING_CONFIG에 적용
|
||||
* window.learningConfigData가 있으면 completed 상태를 업데이트
|
||||
*/
|
||||
if (typeof window !== "undefined" && window.learningConfigData) {
|
||||
try {
|
||||
const configData = window.learningConfigData;
|
||||
|
||||
if (!configData || typeof configData !== 'object') {
|
||||
console.warn('[LEARNING_CONFIG] learningConfigData가 유효하지 않습니다.');
|
||||
} else {
|
||||
LEARNING_CONFIG.chapters.forEach((chapter, chapterIndex) => {
|
||||
try {
|
||||
if (!chapter || !chapter.id) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}가 유효하지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chapterData = configData[chapter.code] ?? configData[chapter.id];
|
||||
|
||||
if (chapterData) {
|
||||
// 챕터 완료 상태 업데이트
|
||||
if (chapterData.completed !== undefined) {
|
||||
chapter.completed = chapterData.completed === true;
|
||||
}
|
||||
|
||||
// 레슨 완료 상태 업데이트
|
||||
if (chapterData.lessons && Array.isArray(chapterData.lessons)) {
|
||||
if (!chapter.lessons || !Array.isArray(chapter.lessons)) {
|
||||
console.warn(`[LEARNING_CONFIG] 챕터 ${chapterIndex}의 lessons가 유효하지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
chapterData.lessons.forEach((lessonData, index) => {
|
||||
try {
|
||||
if (chapter.lessons[index] && lessonData && lessonData.completed !== undefined) {
|
||||
chapter.lessons[index].completed = lessonData.completed === true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[LEARNING_CONFIG] 레슨 ${index} 업데이트 에러:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: 'LEARNING_CONFIG.loadFromHTML.chapter',
|
||||
chapterIndex
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[LEARNING_CONFIG] 챕터 ${chapterIndex} 업데이트 에러:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[LEARNING_CONFIG] learningConfigData 적용 완료:",
|
||||
LEARNING_CONFIG
|
||||
);
|
||||
|
||||
// 설정 유효성 검증
|
||||
if (LEARNING_CONFIG.validate) {
|
||||
const isValid = LEARNING_CONFIG.validate();
|
||||
if (!isValid) {
|
||||
console.warn('[LEARNING_CONFIG] 설정 유효성 검증 실패');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof ErrorHandler !== 'undefined' && ErrorHandler) {
|
||||
ErrorHandler.handle(error, {
|
||||
context: 'LEARNING_CONFIG.loadFromHTML'
|
||||
}, false);
|
||||
} else {
|
||||
console.error('[LEARNING_CONFIG] learningConfigData 적용 에러:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* 게이지 진행률 관리 클래스
|
||||
* 공통 모듈 활용 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 학습 페이지 초기화 및 관리
|
||||
* 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils)
|
||||
*/
|
||||
class LearningApp {
|
||||
constructor(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 {
|
||||
// HTML data 속성에서 설정 읽기
|
||||
this._loadSettingsFromHTML();
|
||||
|
||||
// 의존성 전달을 위한 객체 생성
|
||||
const commonDependencies = {
|
||||
domUtils: this.domUtils,
|
||||
errorHandler: this.errorHandler,
|
||||
eventManager: this.eventManager,
|
||||
utils: this.utils,
|
||||
animationUtils: this.animationUtils
|
||||
};
|
||||
|
||||
// GaugeManager 초기화 (의존성 주입)
|
||||
this.gauge = new GaugeManager(commonDependencies);
|
||||
|
||||
// MarkerManager 초기화
|
||||
this.markerManager = new MarkerManager(this.gauge, LEARNING_CONFIG);
|
||||
|
||||
// ChapterCardManager 초기화 (의존성 주입)
|
||||
this.chapterCardManager = new ChapterCardManager(
|
||||
LEARNING_CONFIG,
|
||||
this.gauge,
|
||||
commonDependencies
|
||||
);
|
||||
|
||||
// ProgressIndicator 초기화
|
||||
this.progressIndicator = new ProgressIndicator(
|
||||
LEARNING_CONFIG,
|
||||
this.gauge,
|
||||
this.markerManager
|
||||
);
|
||||
|
||||
// VideoModal은 선택 의존성으로 처리하여 모달 스크립트 문제 시에도
|
||||
// 경로/마커/챕터 카드는 정상 렌더링되도록 한다.
|
||||
this.modal = null;
|
||||
if (typeof VideoModal !== 'undefined') {
|
||||
this.modal = new VideoModal(LEARNING_CONFIG, this.markerManager);
|
||||
|
||||
if (this.markerManager && typeof this.markerManager.setModalInstance === 'function') {
|
||||
this.markerManager.setModalInstance(this.modal);
|
||||
}
|
||||
if (this.chapterCardManager && typeof this.chapterCardManager.setModalInstance === 'function') {
|
||||
this.chapterCardManager.setModalInstance(this.modal);
|
||||
}
|
||||
} else {
|
||||
console.warn('[LearningApp] VideoModal이 없어 모달 기능은 비활성화됩니다.');
|
||||
this.modal = this._createFallbackModalHandler();
|
||||
|
||||
if (this.markerManager && typeof this.markerManager.setModalInstance === 'function') {
|
||||
this.markerManager.setModalInstance(this.modal);
|
||||
}
|
||||
if (this.chapterCardManager && typeof this.chapterCardManager.setModalInstance === 'function') {
|
||||
this.chapterCardManager.setModalInstance(this.modal);
|
||||
}
|
||||
}
|
||||
|
||||
this.init();
|
||||
} catch (error) {
|
||||
this._handleError(error, 'LearningApp.constructor');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.handle(error, {
|
||||
context: `LearningApp.${context}`,
|
||||
component: 'LearningApp',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[LearningApp] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML data 속성에서 설정 읽기
|
||||
* @private
|
||||
*/
|
||||
_loadSettingsFromHTML() {
|
||||
try {
|
||||
const learningGauge = this.domUtils?.$(".lessons-gauge") || document.querySelector(".lessons-gauge");
|
||||
if (!learningGauge) {
|
||||
console.warn("[LearningApp] .lessons-gauge 요소를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// data-allow-disabled-click
|
||||
const allowDisabledClick = learningGauge.dataset.allowDisabledClick;
|
||||
if (allowDisabledClick !== undefined) {
|
||||
LEARNING_CONFIG.settings.allowDisabledClick =
|
||||
allowDisabledClick === "true";
|
||||
}
|
||||
|
||||
// data-show-disabled-alert
|
||||
const showDisabledAlert = learningGauge.dataset.showDisabledAlert;
|
||||
if (showDisabledAlert !== undefined) {
|
||||
LEARNING_CONFIG.settings.showDisabledAlert = showDisabledAlert === "true";
|
||||
}
|
||||
|
||||
// data-disabled-click-message
|
||||
const disabledClickMessage = learningGauge.dataset.disabledClickMessage;
|
||||
if (disabledClickMessage) {
|
||||
LEARNING_CONFIG.settings.disabledClickMessage = disabledClickMessage;
|
||||
}
|
||||
|
||||
console.log("[LearningApp] HTML 설정 로드 완료:", LEARNING_CONFIG.settings);
|
||||
} catch (error) {
|
||||
this._handleError(error, '_loadSettingsFromHTML');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
init() {
|
||||
try {
|
||||
const initHandler = () => {
|
||||
try {
|
||||
this._initializeComponents();
|
||||
} catch (error) {
|
||||
this._handleError(error, 'init.initHandler');
|
||||
}
|
||||
};
|
||||
|
||||
// DOMContentLoaded 이벤트 처리
|
||||
if (document.readyState === 'loading') {
|
||||
if (this.eventManager) {
|
||||
const listenerId = this.eventManager.on(window, "DOMContentLoaded", initHandler);
|
||||
this.listenerIds.push({ element: window, id: listenerId, type: 'DOMContentLoaded' });
|
||||
} else {
|
||||
window.addEventListener("DOMContentLoaded", initHandler);
|
||||
}
|
||||
} else {
|
||||
// 이미 로드된 경우 즉시 실행
|
||||
initHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'init');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화
|
||||
* @private
|
||||
*/
|
||||
_initializeComponents() {
|
||||
try {
|
||||
const useMarkers = LEARNING_CONFIG?.settings?.useMarkers !== false;
|
||||
|
||||
// 마커 생성 (설정에서 비활성화 가능)
|
||||
if (useMarkers && this.markerManager && typeof this.markerManager.createMarkers === 'function') {
|
||||
this.markerManager.createMarkers();
|
||||
} else if (!useMarkers) {
|
||||
console.log("[LearningApp] 마커 UI 비활성화: createMarkers 생략");
|
||||
} else {
|
||||
console.warn("[LearningApp] markerManager.createMarkers를 호출할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 챕터 카드 생성
|
||||
if (this.chapterCardManager && typeof this.chapterCardManager.createChapterCards === 'function') {
|
||||
this.chapterCardManager.createChapterCards();
|
||||
} else {
|
||||
console.warn("[LearningApp] chapterCardManager.createChapterCards를 호출할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 진행률 표시 생성
|
||||
if (this.progressIndicator && typeof this.progressIndicator.createIndicator === 'function') {
|
||||
this.progressIndicator.createIndicator();
|
||||
} else {
|
||||
console.warn("[LearningApp] progressIndicator.createIndicator를 호출할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 초기 진행률 설정
|
||||
this._initializeProgress();
|
||||
} catch (error) {
|
||||
this._handleError(error, '_initializeComponents');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기 진행률 설정
|
||||
* @private
|
||||
*/
|
||||
_initializeProgress() {
|
||||
try {
|
||||
if (!this.gauge || !this.markerManager) {
|
||||
console.warn("[LearningApp] gauge 또는 markerManager가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 초기 진행률 설정 (마커의 실제 DOM 위치 기반)
|
||||
const targetMarkerConfig = this.gauge.calculateInitialProgress(
|
||||
this.markerManager.allMarkers,
|
||||
LEARNING_CONFIG
|
||||
);
|
||||
|
||||
if (!targetMarkerConfig) {
|
||||
console.warn("[LearningApp] 타겟 마커 설정을 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 강의만 카운트 (챕터 제외)
|
||||
const learningMarkers = (this.markerManager.allMarkers || []).filter(
|
||||
(m) => m && m.isLearningContent !== false
|
||||
);
|
||||
const completedLearningCount = learningMarkers.filter(
|
||||
(m) => m && m.completed === true
|
||||
).length;
|
||||
|
||||
// 마커의 실제 DOM 위치를 찾아서 가장 가까운 pathPercent 계산
|
||||
let initialPathPercent = 0;
|
||||
|
||||
// 100% 완료 시 게이지바를 100%로 설정
|
||||
if (completedLearningCount >= learningMarkers.length) {
|
||||
initialPathPercent = 1.0; // 100% 완료
|
||||
console.log(
|
||||
`[LearningApp] 초기 진행률: 모든 학습 완료, 게이지바 100%로 설정`
|
||||
);
|
||||
} else if (targetMarkerConfig) {
|
||||
// 타겟 마커 찾기 (pathPercent와 label로 비교)
|
||||
const targetMarker = (this.markerManager.markers || []).find(
|
||||
(m) =>
|
||||
m &&
|
||||
m.config &&
|
||||
m.config.pathPercent === targetMarkerConfig.pathPercent &&
|
||||
m.config.label === targetMarkerConfig.label
|
||||
);
|
||||
|
||||
if (targetMarker && targetMarker.element) {
|
||||
// gaugePercent가 있으면 우선 사용, 없으면 마커의 실제 DOM 위치 기반으로 계산
|
||||
if (targetMarkerConfig.gaugePercent !== undefined) {
|
||||
initialPathPercent = targetMarkerConfig.gaugePercent;
|
||||
console.log(
|
||||
`[LearningApp] 초기 진행률: gaugePercent 사용: ${(initialPathPercent * 100).toFixed(1)}%`
|
||||
);
|
||||
} else {
|
||||
// 마커의 실제 DOM 위치 가져오기
|
||||
const markerLeft = parseFloat(targetMarker.element.style.left) || 0;
|
||||
const markerTop = parseFloat(targetMarker.element.style.top) || 0;
|
||||
|
||||
// maskPath에서 마커 위치에 가장 가까운 지점 찾기
|
||||
const closestPercent = this.gauge.findClosestPathPercent(markerLeft, markerTop);
|
||||
if (closestPercent !== null && closestPercent !== undefined) {
|
||||
initialPathPercent = closestPercent;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[LearningApp] 초기 진행률: 마커 실제 위치 (${markerLeft.toFixed(2)}%, ${markerTop.toFixed(2)}%) → pathPercent: ${initialPathPercent.toFixed(4)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 마커를 찾을 수 없는 경우 gaugePercent 우선 사용, 없으면 pathPercent 사용
|
||||
initialPathPercent = targetMarkerConfig.gaugePercent !== undefined
|
||||
? targetMarkerConfig.gaugePercent
|
||||
: (targetMarkerConfig.pathPercent || 0);
|
||||
console.log(
|
||||
`[LearningApp] 초기 진행률: 마커를 찾을 수 없음, ${targetMarkerConfig.gaugePercent !== undefined ? 'gaugePercent' : 'pathPercent'} 직접 사용: ${(initialPathPercent * 100).toFixed(1)}%`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 마커 실제 위치에 가장 가까운 pathPercent를 사용하여 채움 (초기 로딩 시 애니메이션 없음)
|
||||
if (this.gauge && typeof this.gauge.setProgress === 'function') {
|
||||
this.gauge.setProgress(initialPathPercent, true, false);
|
||||
}
|
||||
|
||||
// 진행률 표시 업데이트
|
||||
if (this.progressIndicator && typeof this.progressIndicator.updateProgress === 'function') {
|
||||
this.progressIndicator.updateProgress(this.markerManager.allMarkers);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, '_initializeProgress');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 학습 완료 후 챕터 카드 및 진행률 표시 업데이트
|
||||
*/
|
||||
updateChapterCards() {
|
||||
try {
|
||||
if (this.chapterCardManager && typeof this.chapterCardManager.updateChapterCards === 'function') {
|
||||
this.chapterCardManager.updateChapterCards();
|
||||
}
|
||||
|
||||
if (this.progressIndicator && typeof this.progressIndicator.updateProgress === 'function' && this.markerManager) {
|
||||
this.progressIndicator.updateProgress(this.markerManager.allMarkers);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'updateChapterCards');
|
||||
}
|
||||
}
|
||||
|
||||
_createFallbackModalHandler() {
|
||||
const app = this;
|
||||
let modalEl = null;
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const toEmbedUrl = (rawUrl) => {
|
||||
const raw = String(rawUrl ?? '').trim();
|
||||
const match = raw.match(/(?:v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{11})/);
|
||||
const id = match ? match[1] : raw;
|
||||
return `https://www.youtube.com/embed/${id}?rel=0&modestbranding=1`;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (!modalEl) return;
|
||||
modalEl.remove();
|
||||
modalEl = null;
|
||||
if (typeof bodyUnlock === 'function') bodyUnlock();
|
||||
};
|
||||
|
||||
const renderChapter = (chapter, lessonIndex) => {
|
||||
if (!modalEl || !chapter || !Array.isArray(chapter.lessons)) return;
|
||||
|
||||
const safeIndex = Math.max(0, Math.min(chapter.lessons.length - 1, lessonIndex));
|
||||
const lesson = chapter.lessons[safeIndex] || {};
|
||||
const title = lesson.title || chapter.name || '학습 영상';
|
||||
const iframe = modalEl.querySelector('#videoFrame');
|
||||
const heading = modalEl.querySelector('.video-info h3');
|
||||
const subTitle = modalEl.querySelector('.video-header .sub-txt');
|
||||
|
||||
if (iframe) iframe.src = toEmbedUrl(lesson.url);
|
||||
if (heading) heading.textContent = chapter.name || '';
|
||||
if (subTitle) subTitle.textContent = title;
|
||||
|
||||
const total = chapter.lessons.length;
|
||||
const percent = total > 0 ? Math.round(((safeIndex + 1) / total) * 100) : 0;
|
||||
const gaugeFill = modalEl.querySelector('#gaugeFill');
|
||||
const gaugeLabel = modalEl.querySelector('#currentValue em');
|
||||
const stepLabel = modalEl.querySelector('.gauge-labels .label em');
|
||||
if (gaugeFill) gaugeFill.style.width = `${percent}%`;
|
||||
if (gaugeLabel) gaugeLabel.textContent = String(percent);
|
||||
if (stepLabel) stepLabel.textContent = String(safeIndex + 1);
|
||||
|
||||
const listWrap = modalEl.querySelector('.learning-list');
|
||||
if (!listWrap) return;
|
||||
|
||||
listWrap.innerHTML = chapter.lessons.map((item, idx) => {
|
||||
const activeClass = idx === safeIndex ? 'active' : (item.completed ? 'complet' : '');
|
||||
const stateText = idx === safeIndex ? '학습중' : (item.completed ? '학습완료' : '미진행');
|
||||
return `
|
||||
<li class="${activeClass}">
|
||||
<a href="#" class="list" data-lesson-index="${idx}">
|
||||
<span class="seq">${idx + 1}차시</span>
|
||||
<div class="learning-box">
|
||||
<div class="txt-box">
|
||||
<div class="title">${escapeHtml(item.title || chapter.name || '')}</div>
|
||||
<span class="state">${stateText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
listWrap.querySelectorAll('a.list[data-lesson-index]').forEach((a) => {
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const next = Number.parseInt(a.getAttribute('data-lesson-index') || '0', 10);
|
||||
renderChapter(chapter, Number.isFinite(next) ? next : 0);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
async loadChapter(chapter, chapterIndex, initialLessonIndex) {
|
||||
if (!chapter || !Array.isArray(chapter.lessons) || chapter.lessons.length === 0) return;
|
||||
|
||||
const chapterInfo = LEARNING_CONFIG.getChapterByGlobalIndex
|
||||
? LEARNING_CONFIG.getChapterByGlobalIndex(initialLessonIndex)
|
||||
: null;
|
||||
const lessonIndex = chapterInfo && typeof chapterInfo.lessonIndex === 'number' && chapterInfo.lessonIndex >= 0
|
||||
? chapterInfo.lessonIndex
|
||||
: 0;
|
||||
|
||||
closeModal();
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.innerHTML = `
|
||||
<div class="modal video on" style="display:block;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<div class="video-contents">
|
||||
<div class="video-area"><div class="video-box"><iframe id="videoFrame" width="100%" height="100%" src="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div></div>
|
||||
<div class="video-info"><div class="tit-box"><div class="meta"><span>법정교육</span><em></em></div><h3></h3></div></div>
|
||||
</div>
|
||||
<div class="video-side step">
|
||||
<div class="video-header">
|
||||
<div class="tit-box"><h5 class="tit">현재강의</h5><p class="sub-txt"></p></div>
|
||||
<div class="gauge-container"><div class="gauge-bar"><div class="gauge-fill" id="gaugeFill" style="width:0%"></div></div><div class="gauge-labels"><span class="label"><em>1</em>/${chapter.lessons.length} 강</span><span class="label current" id="currentValue">진도율 <em>0</em>%</span></div></div>
|
||||
<span class="close" role="button" tabindex="0">×</span>
|
||||
</div>
|
||||
<div class="video-list"><h5 class="tit">학습목차</h5><ul class="learning-list"></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const rootModal = modalEl.firstElementChild;
|
||||
if (!rootModal) return;
|
||||
document.body.appendChild(rootModal);
|
||||
modalEl = rootModal;
|
||||
|
||||
const closeBtn = modalEl.querySelector('.close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
modalEl.addEventListener('click', (e) => {
|
||||
if (e.target === modalEl) closeModal();
|
||||
});
|
||||
|
||||
if (typeof bodyLock === 'function') bodyLock();
|
||||
renderChapter(chapter, lessonIndex);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리 (이벤트 리스너 제거)
|
||||
*/
|
||||
destroy() {
|
||||
try {
|
||||
// 이벤트 리스너 제거
|
||||
if (this.eventManager && this.listenerIds.length > 0) {
|
||||
this.listenerIds.forEach(({ element, id }) => {
|
||||
this.eventManager.off(element, id);
|
||||
});
|
||||
this.listenerIds = [];
|
||||
}
|
||||
|
||||
// 컴포넌트 정리
|
||||
if (this.chapterCardManager && typeof this.chapterCardManager.destroy === 'function') {
|
||||
this.chapterCardManager.destroy();
|
||||
}
|
||||
|
||||
// 참조 정리
|
||||
this.gauge = null;
|
||||
this.markerManager = null;
|
||||
this.chapterCardManager = null;
|
||||
this.progressIndicator = null;
|
||||
this.modal = null;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'destroy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 학습 앱 초기화 함수 (에러 처리 포함)
|
||||
* @param {Object} dependencies - 의존성 객체
|
||||
*/
|
||||
function initLearningApp(dependencies = {}) {
|
||||
try {
|
||||
// 의존성 주입 (없으면 자동 감지)
|
||||
const finalDependencies = {
|
||||
domUtils: dependencies.domUtils || (typeof DOMUtils !== 'undefined' ? DOMUtils : null),
|
||||
errorHandler: dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null),
|
||||
eventManager: dependencies.eventManager || (typeof eventManager !== 'undefined' ? eventManager : null),
|
||||
utils: dependencies.utils || (typeof Utils !== 'undefined' ? Utils : null),
|
||||
animationUtils: dependencies.animationUtils || (typeof AnimationUtils !== 'undefined' ? AnimationUtils : null),
|
||||
...dependencies
|
||||
};
|
||||
|
||||
// LEARNING_CONFIG 유효성 검증
|
||||
if (typeof LEARNING_CONFIG === 'undefined') {
|
||||
const error = new Error('LEARNING_CONFIG가 정의되지 않았습니다.');
|
||||
if (finalDependencies.errorHandler) {
|
||||
finalDependencies.errorHandler.handle(error, {
|
||||
context: 'initLearningApp'
|
||||
}, true); // 사용자에게 표시
|
||||
} else {
|
||||
console.error('[LearningApp]', error);
|
||||
alert('학습 설정을 불러올 수 없습니다.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// LearningApp 인스턴스 생성
|
||||
window.learningApp = new LearningApp(finalDependencies);
|
||||
|
||||
if (!window.learningApp) {
|
||||
throw new Error('LearningApp 인스턴스 생성 실패');
|
||||
}
|
||||
|
||||
console.log('[LearningApp] 초기화 완료');
|
||||
} catch (error) {
|
||||
const errorHandler = dependencies.errorHandler || (typeof ErrorHandler !== 'undefined' ? ErrorHandler : null);
|
||||
if (errorHandler) {
|
||||
errorHandler.handle(error, {
|
||||
context: 'initLearningApp'
|
||||
}, true); // 사용자에게 표시
|
||||
} else {
|
||||
console.error('[LearningApp] 초기화 에러:', error);
|
||||
alert('학습 앱 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
if (document.readyState === 'loading') {
|
||||
// DOMContentLoaded는 defer 스크립트가 모두 로드된 후에 발생
|
||||
if (typeof eventManager !== 'undefined' && eventManager) {
|
||||
eventManager.on(document, 'DOMContentLoaded', () => initLearningApp());
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => initLearningApp());
|
||||
}
|
||||
} else {
|
||||
// 이미 로드된 경우 즉시 시도
|
||||
initLearningApp();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user