Initial commit: 교육 프로젝트 배포

This commit is contained in:
송대일
2026-07-01 18:32:42 +09:00
commit be6dccd120
1483 changed files with 5082202 additions and 0 deletions
+594
View File
@@ -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');
}
}
}
+820
View File
@@ -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);
}
}
}
+835
View File
@@ -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);
}
}
}
+506
View File
@@ -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;
}
}
}
+537
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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">&times;</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
+2263
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff