Files
edu/js/learning/main.js
T

538 lines
20 KiB
JavaScript

/**
* 학습 페이지 초기화 및 관리
* 공통 모듈 활용 (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, '&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();
}