Initial commit: 교육 프로젝트 배포
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
// ============================================
|
||||
// 게이지 차트 모듈 (이미지 아이콘 버전)
|
||||
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils)
|
||||
// ============================================
|
||||
|
||||
class GaugeChart {
|
||||
// 상수 정의
|
||||
static CONSTANTS = {
|
||||
MIN_PERCENT: 0.14, // 최소 표시 퍼센트 (14%)
|
||||
ANIMATION_DURATION: 1400, // 애니메이션 시간 (ms)
|
||||
PHASE2_DURATION: 800, // 초과 시 2단계 애니메이션 시간
|
||||
ICON_HIDE_THRESHOLD: 0.7, // 아이콘 숨김 임계값 (70%)
|
||||
};
|
||||
|
||||
static ICON_CONFIG = {
|
||||
rocket: { width: 18, height: 48, offsetX: 9, offsetY: 24, outerOffset: 30 },
|
||||
snail: {
|
||||
width: 75, // 50 → 75 (1.5배)
|
||||
height: 64.5, // 43 → 64.5 (1.5배)
|
||||
offsetX: 37.5, // 25 → 37.5 (1.5배)
|
||||
offsetY: 32.25, // 21.5 → 32.25 (1.5배)
|
||||
outerOffset: 30, // 유지
|
||||
},
|
||||
};
|
||||
|
||||
constructor(config, 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 {
|
||||
if (!config || typeof config !== 'object') {
|
||||
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
|
||||
config = {};
|
||||
}
|
||||
|
||||
this.config = {
|
||||
size: config.size || 832,
|
||||
strokeWidth: config.strokeWidth || 31,
|
||||
maxValue: config.maxValue || 50,
|
||||
padding: config.padding || 20,
|
||||
outerTextOffset: config.outerTextOffset || 6,
|
||||
innerTextOffset: config.innerTextOffset || 35,
|
||||
dotRadius: config.dotRadius || 7,
|
||||
// 아이콘 이미지 경로 설정
|
||||
rocketIconPath:
|
||||
config.rocketIconPath || "/img/ico/icon-rocket.svg",
|
||||
snailIconPath:
|
||||
config.snailIconPath || "/img/ico/icon-snail.svg",
|
||||
};
|
||||
|
||||
this.svg = null;
|
||||
this.center = 0;
|
||||
this.radius = 0;
|
||||
this.animated = false;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'constructor');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.handle(error, {
|
||||
context: `GaugeChart.${context}`,
|
||||
component: 'GaugeChart',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[GaugeChart] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 초기화
|
||||
// ============================================
|
||||
|
||||
init() {
|
||||
try {
|
||||
this.svg = this.domUtils?.$("#gauge") || document.getElementById("gauge");
|
||||
if (!this.svg) {
|
||||
this._handleError(new Error('게이지 SVG 요소를 찾을 수 없습니다.'), 'init');
|
||||
return;
|
||||
}
|
||||
|
||||
this._calculateDimensions();
|
||||
this._setupViewBox();
|
||||
} catch (error) {
|
||||
this._handleError(error, 'init');
|
||||
}
|
||||
}
|
||||
|
||||
_calculateDimensions() {
|
||||
try {
|
||||
const { size, strokeWidth, padding } = this.config;
|
||||
if (typeof size !== 'number' || typeof strokeWidth !== 'number' || typeof padding !== 'number') {
|
||||
this._handleError(new Error('config 값이 유효하지 않습니다.'), '_calculateDimensions');
|
||||
return;
|
||||
}
|
||||
|
||||
this.center = size / 2;
|
||||
this.radius = size / 2 - strokeWidth / 2 - padding;
|
||||
} catch (error) {
|
||||
this._handleError(error, '_calculateDimensions');
|
||||
}
|
||||
}
|
||||
|
||||
_setupViewBox() {
|
||||
try {
|
||||
if (!this.svg) {
|
||||
this._handleError(new Error('svg 요소가 없습니다.'), '_setupViewBox');
|
||||
return;
|
||||
}
|
||||
|
||||
const { size } = this.config;
|
||||
if (typeof size !== 'number') {
|
||||
this._handleError(new Error('size가 유효하지 않습니다.'), '_setupViewBox');
|
||||
return;
|
||||
}
|
||||
|
||||
const extraSpace = 100;
|
||||
this.svg.setAttribute(
|
||||
"viewBox",
|
||||
`-50 -50 ${size + 100} ${size + extraSpace}`
|
||||
);
|
||||
this.svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
} catch (error) {
|
||||
this._handleError(error, '_setupViewBox');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 게이지 업데이트
|
||||
// ============================================
|
||||
|
||||
update(value, labelValue = value) {
|
||||
try {
|
||||
if (!this.svg) {
|
||||
console.warn("[GaugeChart] svg 요소가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 입력값 유효성 검증
|
||||
if (typeof value !== 'number' || isNaN(value) || value < 0) {
|
||||
this._handleError(new Error(`유효하지 않은 value: ${value}`), 'update');
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAnimate = !this.animated;
|
||||
this.svg.innerHTML = "";
|
||||
|
||||
const { percent, angle } = this._calculateAngle(value);
|
||||
const isOverMax = value > this.config.maxValue;
|
||||
|
||||
this._createTextPaths();
|
||||
this._createBackgroundArc(isOverMax);
|
||||
this._createFilledArc(angle, isOverMax, shouldAnimate);
|
||||
this._addMyLearningText(value, labelValue, percent, angle, shouldAnimate);
|
||||
this._addEndIcon(angle, value, shouldAnimate);
|
||||
this._addAverageLearningText(value, percent);
|
||||
|
||||
if (shouldAnimate) {
|
||||
this.animated = true;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'update', { value });
|
||||
}
|
||||
}
|
||||
|
||||
_calculateAngle(value) {
|
||||
const { maxValue } = this.config;
|
||||
const { MIN_PERCENT } = GaugeChart.CONSTANTS;
|
||||
|
||||
let percent;
|
||||
|
||||
if (value <= maxValue) {
|
||||
percent = value / maxValue;
|
||||
percent = Math.max(percent, MIN_PERCENT);
|
||||
} else {
|
||||
percent = 2 - value / maxValue;
|
||||
percent = Math.max(percent, 0.1);
|
||||
}
|
||||
|
||||
const angle = -180 + percent * 180;
|
||||
|
||||
return { percent, angle };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SVG 생성 메서드
|
||||
// ============================================
|
||||
|
||||
_createTextPaths() {
|
||||
const defs = this._createSVGElement("defs");
|
||||
|
||||
// 외곽 텍스트 경로
|
||||
const outerArc = this._createPathElement(
|
||||
"outerArc",
|
||||
this._createArcPath(this.radius - this.config.outerTextOffset, -180, 0)
|
||||
);
|
||||
defs.appendChild(outerArc);
|
||||
|
||||
// 내부 텍스트 경로
|
||||
const innerArc = this._createPathElement(
|
||||
"innerArc",
|
||||
this._createArcPath(this.radius - this.config.innerTextOffset, -180, 0)
|
||||
);
|
||||
defs.appendChild(innerArc);
|
||||
|
||||
this.svg.appendChild(defs);
|
||||
}
|
||||
|
||||
_createBackgroundArc(isOverMax) {
|
||||
const bg = this._createPathElement(
|
||||
null,
|
||||
this._createArcPath(this.radius, -180, 0)
|
||||
);
|
||||
|
||||
if (isOverMax) {
|
||||
bg.setAttribute("stroke", "#D8823B");
|
||||
} else {
|
||||
bg.setAttribute("class", "bg-arc");
|
||||
bg.setAttribute("stroke", "#72451F");
|
||||
bg.setAttribute("stroke-opacity", "0.1");
|
||||
}
|
||||
|
||||
bg.setAttribute("stroke-width", this.config.strokeWidth);
|
||||
bg.setAttribute("stroke-linecap", "round");
|
||||
bg.setAttribute("fill", "none");
|
||||
|
||||
this.svg.appendChild(bg);
|
||||
}
|
||||
|
||||
_createFilledArc(angle, isOverMax, shouldAnimate) {
|
||||
const gradient = this._createGradient(angle);
|
||||
const fg = this._createPathElement(null, "");
|
||||
|
||||
fg.setAttribute("stroke", isOverMax ? "url(#arcSweepGradient)" : "#e89555");
|
||||
fg.setAttribute("stroke-width", this.config.strokeWidth);
|
||||
fg.setAttribute("stroke-linecap", "round");
|
||||
fg.setAttribute("fill", "none");
|
||||
|
||||
this.svg.appendChild(fg);
|
||||
|
||||
if (shouldAnimate) {
|
||||
this._animateFilledArc(fg, angle, isOverMax);
|
||||
} else {
|
||||
fg.setAttribute("d", this._createArcPath(this.radius, -180, angle));
|
||||
}
|
||||
}
|
||||
|
||||
_createGradient(angle) {
|
||||
try {
|
||||
if (!this.svg) {
|
||||
this._handleError(new Error('svg 요소가 없습니다.'), '_createGradient');
|
||||
return null;
|
||||
}
|
||||
|
||||
const defs = this.domUtils?.$("defs", this.svg) || this.svg.querySelector("defs");
|
||||
if (!defs) {
|
||||
this._handleError(new Error('defs 요소를 찾을 수 없습니다.'), '_createGradient');
|
||||
return null;
|
||||
}
|
||||
|
||||
let gradient = this.domUtils?.$("#arcSweepGradient", defs) || defs.querySelector("#arcSweepGradient");
|
||||
|
||||
if (!gradient) {
|
||||
gradient = this._createSVGElement("linearGradient");
|
||||
gradient.setAttribute("id", "arcSweepGradient");
|
||||
gradient.setAttribute("x1", "0%");
|
||||
gradient.setAttribute("y1", "0%");
|
||||
gradient.setAttribute("x2", "100%");
|
||||
gradient.setAttribute("y2", "0%");
|
||||
|
||||
const colors = [
|
||||
{ offset: "0%", color: "#72451F" },
|
||||
{ offset: "100%", color: "#D8823B" },
|
||||
];
|
||||
|
||||
colors.forEach(({ offset, color }) => {
|
||||
const stop = this._createSVGElement("stop");
|
||||
stop.setAttribute("offset", offset);
|
||||
stop.setAttribute("stop-color", color);
|
||||
gradient.appendChild(stop);
|
||||
});
|
||||
|
||||
defs.appendChild(gradient);
|
||||
}
|
||||
|
||||
gradient.setAttribute(
|
||||
"gradientTransform",
|
||||
`rotate(${angle - 90}, 0.5, 0.5)`
|
||||
);
|
||||
|
||||
return gradient;
|
||||
} catch (error) {
|
||||
this._handleError(error, '_createGradient', { angle });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 애니메이션
|
||||
// ============================================
|
||||
|
||||
_animateFilledArc(element, targetAngle, isOverMax) {
|
||||
const { MIN_PERCENT, ANIMATION_DURATION, PHASE2_DURATION } =
|
||||
GaugeChart.CONSTANTS;
|
||||
const startAngle = -180 + MIN_PERCENT * 180;
|
||||
const fullAngle = 0;
|
||||
|
||||
element.setAttribute(
|
||||
"d",
|
||||
this._createArcPath(this.radius, -180, startAngle)
|
||||
);
|
||||
|
||||
if (isOverMax) {
|
||||
this._animateTwoPhase(
|
||||
element,
|
||||
startAngle,
|
||||
fullAngle,
|
||||
targetAngle,
|
||||
ANIMATION_DURATION,
|
||||
PHASE2_DURATION
|
||||
);
|
||||
} else {
|
||||
this._animateSinglePhase(
|
||||
element,
|
||||
startAngle,
|
||||
targetAngle,
|
||||
ANIMATION_DURATION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_animateSinglePhase(element, startAngle, endAngle, duration) {
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeOut = this._easeOutCubic(progress);
|
||||
|
||||
const currentAngle = startAngle + (endAngle - startAngle) * easeOut;
|
||||
element.setAttribute(
|
||||
"d",
|
||||
this._createArcPath(this.radius, -180, currentAngle)
|
||||
);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
_animateTwoPhase(
|
||||
element,
|
||||
startAngle,
|
||||
midAngle,
|
||||
endAngle,
|
||||
phase1Duration,
|
||||
phase2Duration
|
||||
) {
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
|
||||
if (elapsed < phase1Duration) {
|
||||
// Phase 1: 15% → 100%
|
||||
const progress = elapsed / phase1Duration;
|
||||
const easeOut = this._easeOutCubic(progress);
|
||||
const currentAngle = startAngle + (midAngle - startAngle) * easeOut;
|
||||
element.setAttribute(
|
||||
"d",
|
||||
this._createArcPath(this.radius, -180, currentAngle)
|
||||
);
|
||||
requestAnimationFrame(animate);
|
||||
} else if (elapsed < phase1Duration + phase2Duration) {
|
||||
// Phase 2: 100% → 최종값
|
||||
const progress = (elapsed - phase1Duration) / phase2Duration;
|
||||
const easeOut = this._easeOutCubic(progress);
|
||||
const currentAngle = midAngle + (endAngle - midAngle) * easeOut;
|
||||
element.setAttribute(
|
||||
"d",
|
||||
this._createArcPath(this.radius, -180, currentAngle)
|
||||
);
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
// 완료
|
||||
element.setAttribute(
|
||||
"d",
|
||||
this._createArcPath(this.radius, -180, endAngle)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 텍스트 및 아이콘
|
||||
// ============================================
|
||||
|
||||
_addMyLearningText(value, labelValue, percent, angle, shouldAnimate) {
|
||||
try {
|
||||
if (!this.svg) {
|
||||
this._handleError(new Error('svg 요소가 없습니다.'), '_addMyLearningText');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = this._createTextElement(
|
||||
"나의 학습",
|
||||
labelValue,
|
||||
percent,
|
||||
this.config.maxValue
|
||||
);
|
||||
|
||||
if (!text) {
|
||||
this._handleError(new Error('text 요소를 생성할 수 없습니다.'), '_addMyLearningText');
|
||||
return;
|
||||
}
|
||||
|
||||
this.svg.appendChild(text);
|
||||
|
||||
const textPath = this.domUtils?.$("textPath", text) || text.querySelector("textPath");
|
||||
if (!textPath) {
|
||||
this._handleError(new Error('textPath 요소를 찾을 수 없습니다.'), '_addMyLearningText');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalOffset = this._calculateTextOffset(value, percent);
|
||||
|
||||
if (shouldAnimate) {
|
||||
this._animateText(
|
||||
textPath,
|
||||
15,
|
||||
finalOffset,
|
||||
GaugeChart.CONSTANTS.ANIMATION_DURATION
|
||||
);
|
||||
} else {
|
||||
textPath.setAttribute("startOffset", `${finalOffset}%`);
|
||||
}
|
||||
|
||||
// 클릭 이벤트 추가: mypage.html로 이동
|
||||
if (this.domUtils) {
|
||||
this.domUtils.setStyles(text, { cursor: "pointer" });
|
||||
} else {
|
||||
text.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
const clickHandler = () => {
|
||||
try {
|
||||
window.location.href = "mypage.php";
|
||||
} catch (error) {
|
||||
this._handleError(error, '_addMyLearningText.clickHandler');
|
||||
}
|
||||
};
|
||||
|
||||
if (this.eventManager) {
|
||||
const listenerId = this.eventManager.on(text, "click", clickHandler);
|
||||
this.listenerIds.push({ element: text, id: listenerId, type: 'click' });
|
||||
} else {
|
||||
text.addEventListener("click", clickHandler);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, '_addMyLearningText', { value, labelValue, percent, angle, shouldAnimate });
|
||||
}
|
||||
}
|
||||
|
||||
_createTextElement(label, value, percent, maxValue) {
|
||||
const text = this._createSVGElement("text");
|
||||
text.setAttribute("class", "my-learning-text");
|
||||
text.setAttribute("font-size", "16");
|
||||
text.setAttribute("font-weight", "700");
|
||||
text.setAttribute("fill", "#FFF");
|
||||
text.setAttribute("stroke", "rgba(0, 0, 0, 0.30)");
|
||||
text.setAttribute("stroke-width", "2");
|
||||
text.setAttribute("paint-order", "stroke fill");
|
||||
|
||||
const textPath = this._createSVGElement("textPath");
|
||||
textPath.setAttributeNS(
|
||||
"http://www.w3.org/1999/xlink",
|
||||
"xlink:href",
|
||||
"#outerArc"
|
||||
);
|
||||
textPath.setAttribute("text-anchor", "middle");
|
||||
textPath.textContent = `${label} : ${value}분 >`;
|
||||
|
||||
text.appendChild(textPath);
|
||||
return text;
|
||||
}
|
||||
|
||||
_calculateTextOffset(value, percent) {
|
||||
if (value > this.config.maxValue) {
|
||||
return 95;
|
||||
}
|
||||
return Math.max(10, percent * 100 - 4.5);
|
||||
}
|
||||
|
||||
_animateText(element, startOffset, endOffset, duration) {
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeOut = this._easeOutCubic(progress);
|
||||
const currentOffset = startOffset + (endOffset - startOffset) * easeOut;
|
||||
|
||||
element.setAttribute("startOffset", `${currentOffset}%`);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
_addEndIcon(angle, value, shouldAnimate) {
|
||||
const { ICON_HIDE_THRESHOLD } = GaugeChart.CONSTANTS;
|
||||
|
||||
// 70%~100% 사이에는 아이콘 표시하지 않음
|
||||
if (
|
||||
value >= this.config.maxValue * ICON_HIDE_THRESHOLD &&
|
||||
value <= this.config.maxValue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOverMax = value > this.config.maxValue;
|
||||
const iconType = isOverMax ? "rocket" : "snail";
|
||||
const iconConfig = GaugeChart.ICON_CONFIG[iconType];
|
||||
|
||||
const icon = this._createIconElement(iconType);
|
||||
this.svg.appendChild(icon);
|
||||
|
||||
if (shouldAnimate) {
|
||||
this._animateIcon(icon, angle, iconConfig, isOverMax);
|
||||
} else {
|
||||
this._positionIcon(icon, angle, iconConfig, 0);
|
||||
}
|
||||
}
|
||||
|
||||
_createIconElement(type) {
|
||||
const icon = this._createSVGElement("g");
|
||||
|
||||
// image 요소 생성
|
||||
const image = this._createSVGElement("image");
|
||||
const iconConfig = GaugeChart.ICON_CONFIG[type];
|
||||
|
||||
// 이미지 경로 설정
|
||||
const imagePath =
|
||||
type === "rocket"
|
||||
? this.config.rocketIconPath
|
||||
: this.config.snailIconPath;
|
||||
|
||||
image.setAttributeNS(
|
||||
"http://www.w3.org/1999/xlink",
|
||||
"xlink:href",
|
||||
imagePath
|
||||
);
|
||||
image.setAttribute("width", iconConfig.width);
|
||||
image.setAttribute("height", iconConfig.height);
|
||||
|
||||
icon.appendChild(image);
|
||||
return icon;
|
||||
}
|
||||
|
||||
_animateIcon(icon, targetAngle, iconConfig, isOverMax) {
|
||||
const { MIN_PERCENT, ANIMATION_DURATION } = GaugeChart.CONSTANTS;
|
||||
const startAngle = -180 + MIN_PERCENT * 180;
|
||||
const fullAngle = 0;
|
||||
const angleOffset = -3;
|
||||
|
||||
this._positionIcon(icon, startAngle, iconConfig, angleOffset);
|
||||
|
||||
if (isOverMax) {
|
||||
// 초과 시: 100%까지만
|
||||
this._animateIconPosition(
|
||||
icon,
|
||||
startAngle,
|
||||
fullAngle,
|
||||
iconConfig,
|
||||
angleOffset,
|
||||
ANIMATION_DURATION
|
||||
);
|
||||
} else {
|
||||
// 정상 범위: 최종 각도까지
|
||||
this._animateIconPosition(
|
||||
icon,
|
||||
startAngle,
|
||||
targetAngle,
|
||||
iconConfig,
|
||||
angleOffset,
|
||||
ANIMATION_DURATION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_animateIconPosition(
|
||||
icon,
|
||||
startAngle,
|
||||
endAngle,
|
||||
iconConfig,
|
||||
angleOffset,
|
||||
duration
|
||||
) {
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeOut = this._easeOutCubic(progress);
|
||||
|
||||
const currentAngle = startAngle + (endAngle - startAngle) * easeOut;
|
||||
this._positionIcon(icon, currentAngle, iconConfig, angleOffset);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
_positionIcon(icon, angle, iconConfig, angleOffset = 0) {
|
||||
const { offsetX, offsetY, outerOffset } = iconConfig;
|
||||
const position = this._polarToCartesian(
|
||||
this.center,
|
||||
this.center,
|
||||
this.radius + outerOffset,
|
||||
angle + angleOffset
|
||||
);
|
||||
|
||||
const rotation =
|
||||
angle + (iconConfig === GaugeChart.ICON_CONFIG.snail ? 90 : 0);
|
||||
|
||||
icon.setAttribute(
|
||||
"transform",
|
||||
`translate(${position.x - offsetX}, ${position.y - offsetY}) rotate(${rotation}, ${offsetX}, ${offsetY})`
|
||||
);
|
||||
}
|
||||
|
||||
_addAverageLearningText(value, percent) {
|
||||
const text = this._createSVGElement("text");
|
||||
text.setAttribute("font-size", "13");
|
||||
text.setAttribute("font-weight", "400");
|
||||
text.setAttribute("fill", "#4A4947");
|
||||
|
||||
const textPath = this._createSVGElement("textPath");
|
||||
textPath.setAttributeNS(
|
||||
"http://www.w3.org/1999/xlink",
|
||||
"xlink:href",
|
||||
"#innerArc"
|
||||
);
|
||||
|
||||
const offset =
|
||||
value > this.config.maxValue ? Math.max(10, percent * 100) : 100;
|
||||
const avgMinutes = Math.max(0, Math.round(Number(this.config.maxValue) || 0));
|
||||
|
||||
textPath.setAttribute("startOffset", `${offset}%`);
|
||||
textPath.setAttribute("text-anchor", "end");
|
||||
textPath.textContent = `전체 평균 학습 : ${avgMinutes.toLocaleString("ko-KR")}분`;
|
||||
|
||||
text.appendChild(textPath);
|
||||
this.svg.appendChild(text);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 유틸리티 메서드
|
||||
// ============================================
|
||||
|
||||
_createSVGElement(type) {
|
||||
return document.createElementNS("http://www.w3.org/2000/svg", type);
|
||||
}
|
||||
|
||||
_createPathElement(id, d) {
|
||||
const path = this._createSVGElement("path");
|
||||
if (id) path.setAttribute("id", id);
|
||||
if (d) path.setAttribute("d", d);
|
||||
return path;
|
||||
}
|
||||
|
||||
_polarToCartesian(cx, cy, r, angle) {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
return {
|
||||
x: cx + r * Math.cos(rad),
|
||||
y: cy + r * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
_createArcPath(r, startAngle, endAngle) {
|
||||
const start = this._polarToCartesian(
|
||||
this.center,
|
||||
this.center,
|
||||
r,
|
||||
startAngle
|
||||
);
|
||||
const end = this._polarToCartesian(this.center, this.center, r, endAngle);
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 0 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
_easeOutCubic(t) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리 (이벤트 리스너 제거)
|
||||
*/
|
||||
destroy() {
|
||||
try {
|
||||
// 이벤트 리스너 제거
|
||||
if (this.eventManager && this.listenerIds.length > 0) {
|
||||
this.listenerIds.forEach(({ element, id }) => {
|
||||
this.eventManager.off(element, id);
|
||||
});
|
||||
this.listenerIds = [];
|
||||
}
|
||||
|
||||
// 참조 정리
|
||||
this.svg = null;
|
||||
this.config = null;
|
||||
this.center = 0;
|
||||
this.radius = 0;
|
||||
this.animated = false;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'destroy');
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
// ============================================
|
||||
// 비디오 카드 렌더링 모듈
|
||||
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils, AnimationUtils)
|
||||
// ============================================
|
||||
|
||||
class VideoCardRenderer {
|
||||
constructor(config, 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 {
|
||||
// 입력값 유효성 검증
|
||||
if (!config || typeof config !== 'object') {
|
||||
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
|
||||
config = {};
|
||||
}
|
||||
|
||||
this.config = config;
|
||||
this.animationDelay = config.animationDelay || 50;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'constructor');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.handle(error, {
|
||||
context: `VideoCardRenderer.${context}`,
|
||||
component: 'VideoCardRenderer',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[VideoCardRenderer] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// 비디오 카드들 렌더링 (AnimationUtils 활용)
|
||||
async renderCards(videos, containerId = "videoCardsContainer") {
|
||||
try {
|
||||
// 입력값 유효성 검증
|
||||
if (!videos || !Array.isArray(videos)) {
|
||||
this._handleError(new Error('videos가 배열이 아닙니다.'), 'renderCards', { containerId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!containerId || typeof containerId !== 'string') {
|
||||
this._handleError(new Error('containerId가 유효하지 않습니다.'), 'renderCards', { videos });
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.domUtils?.$(`#${containerId}`) || document.querySelector(`#${containerId}`);
|
||||
if (!container) {
|
||||
this._handleError(new Error(`컨테이너를 찾을 수 없습니다: #${containerId}`), 'renderCards', { containerId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.domUtils) {
|
||||
this.domUtils.empty(container);
|
||||
} else {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
const cards = [];
|
||||
videos.forEach((video, index) => {
|
||||
try {
|
||||
const card = this.createVideoCard(video);
|
||||
if (card) {
|
||||
cards.push(card);
|
||||
container.appendChild(card);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'renderCards.createCard', { index, video });
|
||||
}
|
||||
});
|
||||
|
||||
// 순차적 애니메이션 (AnimationUtils 활용)
|
||||
if (this.animationUtils && cards.length > 0) {
|
||||
await this.animationUtils.sequentialAnimate(cards, "show", this.animationDelay);
|
||||
} else if (typeof AnimationUtils !== 'undefined' && cards.length > 0) {
|
||||
await AnimationUtils.sequentialAnimate(cards, "show", this.animationDelay);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'renderCards', { videos, containerId });
|
||||
}
|
||||
}
|
||||
|
||||
// 비디오 카드 생성 (DOMUtils, VideoBase 활용)
|
||||
createVideoCard(video) {
|
||||
try {
|
||||
// 입력값 유효성 검증
|
||||
if (!video || typeof video !== 'object') {
|
||||
this._handleError(new Error('video가 유효하지 않습니다.'), 'createVideoCard');
|
||||
return null;
|
||||
}
|
||||
|
||||
// VideoModel 사용 (있는 경우)
|
||||
let videoModel;
|
||||
if (typeof VideoModel !== 'undefined' && video instanceof VideoModel) {
|
||||
videoModel = video;
|
||||
} else if (typeof VideoModel !== 'undefined') {
|
||||
try {
|
||||
videoModel = new VideoModel(video);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'createVideoCard.VideoModel', { video });
|
||||
// VideoModel 생성 실패 시 원본 video 사용
|
||||
videoModel = video;
|
||||
}
|
||||
} else {
|
||||
videoModel = video;
|
||||
}
|
||||
|
||||
const keywords = videoModel.keywords || [];
|
||||
const keywordTags = Array.isArray(keywords)
|
||||
? keywords.map((kw) => {
|
||||
const escapedKw = this._escapeHtml(String(kw || ''));
|
||||
return `<span class="key-badge">${escapedKw}</span>`;
|
||||
}).join(" ")
|
||||
: "";
|
||||
|
||||
const categoryClass = this.getCategoryClass(videoModel.category);
|
||||
const pickerName = videoModel.picker && String(videoModel.picker).trim();
|
||||
const pickBadge = pickerName
|
||||
? `<div class="pick"><i class="ico-pick"></i>${this._escapeHtml(pickerName)}님<em>Pick!</em></div>`
|
||||
: "";
|
||||
|
||||
const gauge = videoModel.gauge;
|
||||
const gaugeBar = (typeof gauge === 'number' && gauge >= 0 && gauge <= 100)
|
||||
? `<div class="gauge-bar"><div class="gauge-fill" style="width: ${gauge}%"></div></div>`
|
||||
: "";
|
||||
|
||||
// VideoBase를 사용하여 썸네일 URL 생성
|
||||
let thumbnailUrl = '';
|
||||
try {
|
||||
if (videoModel.getThumbnailUrl && typeof videoModel.getThumbnailUrl === 'function') {
|
||||
thumbnailUrl = videoModel.getThumbnailUrl("sd");
|
||||
} else if (typeof VideoBase !== 'undefined' && VideoBase.getYouTubeThumbnail) {
|
||||
const videoId = videoModel.url || videoModel.getVideoId?.() || videoModel.id;
|
||||
thumbnailUrl = VideoBase.getYouTubeThumbnail(videoId, "sd");
|
||||
} else {
|
||||
// 기본 썸네일 URL 생성
|
||||
const videoId = videoModel.url || videoModel.id;
|
||||
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/sddefault.jpg`;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'createVideoCard.thumbnailUrl', { videoModel });
|
||||
thumbnailUrl = '';
|
||||
}
|
||||
|
||||
const videoId = videoModel.id || videoModel.url || '';
|
||||
const title = this._escapeHtml(String(videoModel.title || ''));
|
||||
const category = this._escapeHtml(String(videoModel.category || ''));
|
||||
const isBookmarked = !!videoModel.bookmark;
|
||||
const checkboxChecked = isBookmarked ? ' checked' : '';
|
||||
|
||||
const cardContent = `
|
||||
<a href="#" class="card" data-video-id="${this._escapeHtml(String(videoId))}">
|
||||
<div class="thumb">
|
||||
<img src="${this._escapeHtml(thumbnailUrl)}" alt="${title}" loading="lazy" />
|
||||
</div>
|
||||
<div class="txt-box">
|
||||
<label class="bookmark" for="like_chk${this._escapeHtml(String(videoId))}" onclick="event.stopPropagation();">
|
||||
<input type="checkbox" id="like_chk${this._escapeHtml(String(videoId))}"${checkboxChecked}>
|
||||
</label>
|
||||
<div class="category ${categoryClass}">${category}</div>
|
||||
<div class="title">${title}</div>
|
||||
<div class="author">${keywordTags}</div>
|
||||
</div>
|
||||
${pickBadge}
|
||||
</a>
|
||||
${gaugeBar}
|
||||
`;
|
||||
|
||||
// DOMUtils.createElement 사용
|
||||
if (this.domUtils && this.domUtils.createElement) {
|
||||
return this.domUtils.createElement("div", { class: "video-card" }, cardContent);
|
||||
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.createElement) {
|
||||
return DOMUtils.createElement("div", { class: "video-card" }, cardContent);
|
||||
} else {
|
||||
// 폴백: 직접 DOM 요소 생성
|
||||
const div = document.createElement("div");
|
||||
div.className = "video-card";
|
||||
div.innerHTML = cardContent;
|
||||
return div;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'createVideoCard', { video });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 이스케이프 (XSS 방지)
|
||||
* @private
|
||||
*/
|
||||
_escapeHtml(text) {
|
||||
if (typeof text !== 'string') {
|
||||
text = String(text || '');
|
||||
}
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
// 유틸리티: 카테고리 클래스
|
||||
getCategoryClass(category) {
|
||||
try {
|
||||
if (!category || typeof category !== 'string') {
|
||||
return "default";
|
||||
}
|
||||
|
||||
const map = {
|
||||
리더십: "leader",
|
||||
인사이트: "insight",
|
||||
비즈트렌드: "biz",
|
||||
};
|
||||
return map[category] || "default";
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getCategoryClass', { category });
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리 (이벤트 리스너 제거)
|
||||
*/
|
||||
destroy() {
|
||||
try {
|
||||
// 이벤트 리스너 제거
|
||||
if (this.eventManager && this.listenerIds.length > 0) {
|
||||
this.listenerIds.forEach(({ element, id }) => {
|
||||
this.eventManager.off(element, id);
|
||||
});
|
||||
this.listenerIds = [];
|
||||
}
|
||||
|
||||
// 참조 정리
|
||||
this.config = null;
|
||||
this.animationDelay = 50;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'destroy');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// ============================================
|
||||
// 비디오 슬라이드 모듈 (페이지네이션)
|
||||
// 공통 모듈 활용 (ErrorHandler, DOMUtils, EventManager, Utils, AnimationUtils)
|
||||
// ============================================
|
||||
|
||||
class VideoSlider {
|
||||
constructor(config, 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 {
|
||||
// 입력값 유효성 검증
|
||||
if (!config || typeof config !== 'object') {
|
||||
this._handleError(new Error('config가 유효하지 않습니다.'), 'constructor');
|
||||
config = {};
|
||||
}
|
||||
|
||||
this.config = config;
|
||||
this.videos = Array.isArray(config.videos) ? config.videos : [];
|
||||
this.currentPage = 0;
|
||||
this.videosPerPage = config.videosPerPage || 6;
|
||||
|
||||
// config에 videosPerPage가 없으면 설정
|
||||
if (!this.config.videosPerPage) {
|
||||
this.config.videosPerPage = this.videosPerPage;
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'constructor');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.handle(error, {
|
||||
context: `VideoSlider.${context}`,
|
||||
component: 'VideoSlider',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[VideoSlider] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화
|
||||
init() {
|
||||
try {
|
||||
this.setupEventListeners();
|
||||
this.updatePagination();
|
||||
} catch (error) {
|
||||
this._handleError(error, 'init');
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정 (EventManager 활용)
|
||||
setupEventListeners() {
|
||||
try {
|
||||
const prevBtn = this.domUtils?.$("#prevBtn") ||
|
||||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#prevBtn") : null) ||
|
||||
document.querySelector("#prevBtn");
|
||||
const nextBtn = this.domUtils?.$("#nextBtn") ||
|
||||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#nextBtn") : null) ||
|
||||
document.querySelector("#nextBtn");
|
||||
|
||||
if (!prevBtn || !nextBtn) {
|
||||
console.warn("[VideoSlider] 이전/다음 버튼을 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 이전 버튼 클릭 핸들러
|
||||
const prevHandler = async (e) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.currentPage > 0) {
|
||||
this.currentPage--;
|
||||
await this.changePage();
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupEventListeners.prevHandler');
|
||||
}
|
||||
};
|
||||
|
||||
// 다음 버튼 클릭 핸들러
|
||||
const nextHandler = async (e) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const totalPages = this.getTotalPages();
|
||||
if (this.currentPage < totalPages - 1) {
|
||||
this.currentPage++;
|
||||
await this.changePage();
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupEventListeners.nextHandler');
|
||||
}
|
||||
};
|
||||
|
||||
// EventManager 사용 (있는 경우)
|
||||
if (this.eventManager) {
|
||||
const prevId = this.eventManager.on(prevBtn, "click", prevHandler);
|
||||
const nextId = this.eventManager.on(nextBtn, "click", nextHandler);
|
||||
this.listenerIds.push(
|
||||
{ element: prevBtn, id: prevId, type: 'click' },
|
||||
{ element: nextBtn, id: nextId, type: 'click' }
|
||||
);
|
||||
} else {
|
||||
// 폴백: 직접 이벤트 리스너 등록
|
||||
prevBtn.addEventListener("click", prevHandler);
|
||||
nextBtn.addEventListener("click", nextHandler);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupEventListeners');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 페이지 영상 가져오기
|
||||
getCurrentPageVideos() {
|
||||
try {
|
||||
if (!Array.isArray(this.videos)) {
|
||||
this._handleError(new Error('videos가 배열이 아닙니다.'), 'getCurrentPageVideos');
|
||||
return [];
|
||||
}
|
||||
|
||||
const videosPerPage = this.config.videosPerPage || this.videosPerPage || 6;
|
||||
const start = this.currentPage * videosPerPage;
|
||||
const end = start + videosPerPage;
|
||||
return this.videos.slice(start, end);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getCurrentPageVideos');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 변경 (AnimationUtils 활용)
|
||||
async changePage() {
|
||||
try {
|
||||
const container = this.domUtils?.$("#videoCardsContainer") ||
|
||||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#videoCardsContainer") : null) ||
|
||||
document.querySelector("#videoCardsContainer");
|
||||
|
||||
if (!container) {
|
||||
this._handleError(new Error('videoCardsContainer를 찾을 수 없습니다.'), 'changePage');
|
||||
return;
|
||||
}
|
||||
|
||||
// 페이드 아웃 효과
|
||||
if (this.animationUtils && this.animationUtils.fade) {
|
||||
await this.animationUtils.fade(container, "out", 400);
|
||||
} else if (typeof AnimationUtils !== 'undefined' && AnimationUtils.fade) {
|
||||
await AnimationUtils.fade(container, "out", 400);
|
||||
} else {
|
||||
// 폴백: 직접 페이드 효과
|
||||
container.style.opacity = '0';
|
||||
await (this.utils?.delay(400) || new Promise(resolve => setTimeout(resolve, 400)));
|
||||
}
|
||||
|
||||
// 외부 렌더링 함수 호출
|
||||
if (this.config.onPageChange && typeof this.config.onPageChange === 'function') {
|
||||
try {
|
||||
this.config.onPageChange(this.getCurrentPageVideos());
|
||||
} catch (error) {
|
||||
this._handleError(error, 'changePage.onPageChange');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지네이션 업데이트
|
||||
this.updatePagination();
|
||||
|
||||
// 페이드 인 효과
|
||||
if (this.animationUtils && this.animationUtils.fade) {
|
||||
await this.animationUtils.fade(container, "in", 400);
|
||||
} else if (typeof AnimationUtils !== 'undefined' && AnimationUtils.fade) {
|
||||
await AnimationUtils.fade(container, "in", 400);
|
||||
} else {
|
||||
// 폴백: 직접 페이드 효과
|
||||
container.style.opacity = '1';
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'changePage');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지네이션 업데이트
|
||||
updatePagination() {
|
||||
try {
|
||||
const pagination = this.domUtils?.$("#pagination") ||
|
||||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#pagination") : null) ||
|
||||
document.querySelector("#pagination");
|
||||
const prevBtn = this.domUtils?.$("#prevBtn") ||
|
||||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#prevBtn") : null) ||
|
||||
document.querySelector("#prevBtn");
|
||||
const nextBtn = this.domUtils?.$("#nextBtn") ||
|
||||
(typeof DOMUtils !== 'undefined' ? DOMUtils.$("#nextBtn") : null) ||
|
||||
document.querySelector("#nextBtn");
|
||||
|
||||
if (!pagination) {
|
||||
console.warn("[VideoSlider] pagination 요소를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = this.getTotalPages();
|
||||
const currentPageNum = Math.max(0, Math.min(this.currentPage + 1, totalPages));
|
||||
|
||||
// XSS 방지를 위해 텍스트로 설정
|
||||
pagination.innerHTML = '';
|
||||
const currentSpan = document.createElement('span');
|
||||
currentSpan.className = 'current';
|
||||
currentSpan.textContent = currentPageNum;
|
||||
pagination.appendChild(currentSpan);
|
||||
pagination.appendChild(document.createTextNode(` / ${totalPages}`));
|
||||
|
||||
// 이전 버튼 상태
|
||||
if (prevBtn) {
|
||||
const isDisabled = this.currentPage === 0;
|
||||
if (this.domUtils && this.domUtils.toggleClass) {
|
||||
this.domUtils.toggleClass(prevBtn, "disabled", isDisabled);
|
||||
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.toggleClass) {
|
||||
DOMUtils.toggleClass(prevBtn, "disabled", isDisabled);
|
||||
} else {
|
||||
// 폴백
|
||||
if (isDisabled) {
|
||||
prevBtn.classList.add("disabled");
|
||||
} else {
|
||||
prevBtn.classList.remove("disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 버튼 상태
|
||||
if (nextBtn) {
|
||||
const isDisabled = this.currentPage >= totalPages - 1;
|
||||
if (this.domUtils && this.domUtils.toggleClass) {
|
||||
this.domUtils.toggleClass(nextBtn, "disabled", isDisabled);
|
||||
} else if (typeof DOMUtils !== 'undefined' && DOMUtils.toggleClass) {
|
||||
DOMUtils.toggleClass(nextBtn, "disabled", isDisabled);
|
||||
} else {
|
||||
// 폴백
|
||||
if (isDisabled) {
|
||||
nextBtn.classList.add("disabled");
|
||||
} else {
|
||||
nextBtn.classList.remove("disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'updatePagination');
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 페이지 수
|
||||
getTotalPages() {
|
||||
try {
|
||||
if (!Array.isArray(this.videos)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const videosPerPage = this.config.videosPerPage || this.videosPerPage || 6;
|
||||
if (videosPerPage <= 0) {
|
||||
this._handleError(new Error('videosPerPage가 0 이하입니다.'), 'getTotalPages');
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(this.videos.length / videosPerPage));
|
||||
} catch (error) {
|
||||
this._handleError(error, 'getTotalPages');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리 (이벤트 리스너 제거)
|
||||
*/
|
||||
destroy() {
|
||||
try {
|
||||
// 이벤트 리스너 제거
|
||||
if (this.eventManager && this.listenerIds.length > 0) {
|
||||
this.listenerIds.forEach(({ element, id }) => {
|
||||
this.eventManager.off(element, id);
|
||||
});
|
||||
this.listenerIds = [];
|
||||
}
|
||||
|
||||
// 참조 정리
|
||||
this.config = null;
|
||||
this.videos = [];
|
||||
this.currentPage = 0;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'destroy');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// ============================================
|
||||
// 비디오 모달 관리 모듈 (Videomodalmanager.js)
|
||||
// ============================================
|
||||
//
|
||||
// [역할] 비디오 카드 클릭 시 모달을 열고, YouTube 영상을 재생합니다.
|
||||
//
|
||||
// [파일명 참고] "Videomodalmanager"는 legacy 표기입니다.
|
||||
// 클래스명은 VideoModalManager (PascalCase)를 사용합니다.
|
||||
//
|
||||
// [의존성] VideoModalBase(부모), DOMUtils, EventManager, ErrorHandler, Utils
|
||||
//
|
||||
// [사용 예] index.html 등에서:
|
||||
// const modalManager = new VideoModalManager({ videos: [...] });
|
||||
// modalManager.init();
|
||||
//
|
||||
// ============================================
|
||||
|
||||
class VideoModalManager extends VideoModalBase {
|
||||
constructor(config, dependencies = {}) {
|
||||
// 입력값 유효성 검증 (super 호출 전에 가능한 작업만)
|
||||
if (!config || typeof config !== 'object') {
|
||||
config = {};
|
||||
}
|
||||
|
||||
// VideoModalBase에 전달할 config 준비
|
||||
const baseConfig = {
|
||||
videos: config.videos || [],
|
||||
modalPath: config.modalPath || "./_modal/video.php",
|
||||
modalPathTemplate: config.modalPathTemplate || "./_modal/video-{type}.php",
|
||||
enableHeightAdjustment: config.enableHeightAdjustment !== false,
|
||||
enableCommentResizer: config.enableCommentResizer !== false,
|
||||
enableCommentBox: config.enableCommentBox !== false,
|
||||
...config,
|
||||
};
|
||||
|
||||
// 부모 클래스 생성자 호출 (반드시 먼저 호출)
|
||||
super(baseConfig);
|
||||
|
||||
// 의존성 주입 (폴백 포함) - super() 호출 후
|
||||
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 = [];
|
||||
this._isInitialized = false;
|
||||
this._openVideoPromise = null;
|
||||
|
||||
try {
|
||||
// 추가 초기화 작업
|
||||
} catch (error) {
|
||||
this._handleError(error, 'constructor');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 처리 헬퍼
|
||||
* @private
|
||||
*/
|
||||
_handleError(error, context, additionalInfo = {}) {
|
||||
if (this.errorHandler) {
|
||||
this.errorHandler.handle(error, {
|
||||
context: `VideoModalManager.${context}`,
|
||||
component: 'VideoModalManager',
|
||||
...additionalInfo
|
||||
}, false);
|
||||
} else {
|
||||
console.error(`[VideoModalManager] ${context}:`, error, additionalInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화
|
||||
init() {
|
||||
try {
|
||||
if (this._isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupCardClickEvents();
|
||||
this._isInitialized = true;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'init');
|
||||
}
|
||||
}
|
||||
|
||||
async openVideo(videoIdOrData) {
|
||||
if (this._openVideoPromise) {
|
||||
return this._openVideoPromise;
|
||||
}
|
||||
|
||||
this._openVideoPromise = super.openVideo(videoIdOrData)
|
||||
.finally(() => {
|
||||
this._openVideoPromise = null;
|
||||
});
|
||||
|
||||
return this._openVideoPromise;
|
||||
}
|
||||
|
||||
// 카드 클릭 이벤트 설정 (DOMUtils, EventManager 활용)
|
||||
setupCardClickEvents() {
|
||||
try {
|
||||
const containers = Array.from(document.querySelectorAll('[data-video-cards-container="true"], .js-video-cards-container, #videoCardsContainer'));
|
||||
|
||||
if (containers.length === 0) {
|
||||
console.warn("[VideoModalManager] videoCardsContainer를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// VideoModalManager 인스턴스를 참조하기 위해 변수에 저장
|
||||
const self = this;
|
||||
|
||||
const clickHandler = function (e) {
|
||||
try {
|
||||
// 북마크 클릭은 카드 오픈 로직에서 제외해야 체크 토글/change 저장이 정상 동작한다.
|
||||
if (e.target && e.target.closest && e.target.closest('.bookmark')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = e.target && e.target.closest
|
||||
? e.target.closest('.book-info-btn[data-video-id], .books-item[data-video-id], .card[data-video-id], [data-video-id]')
|
||||
: null;
|
||||
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const videoIdAttr = trigger.getAttribute("data-video-id");
|
||||
if (!videoIdAttr) {
|
||||
console.warn("[VideoModalManager] data-video-id 속성이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = videoIdAttr;
|
||||
if (!videoId) {
|
||||
self._handleError(new Error(`유효하지 않은 videoId: ${videoIdAttr}`), 'setupCardClickEvents.clickHandler');
|
||||
return;
|
||||
}
|
||||
|
||||
self.openVideo(videoId);
|
||||
} catch (error) {
|
||||
self._handleError(error, 'setupCardClickEvents.clickHandler');
|
||||
}
|
||||
};
|
||||
|
||||
containers.forEach((container) => {
|
||||
const nativeHandler = function (e) {
|
||||
clickHandler(e);
|
||||
};
|
||||
|
||||
container.addEventListener("click", nativeHandler);
|
||||
self.listenerIds.push({ element: container, handler: nativeHandler, type: 'native' });
|
||||
});
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupCardClickEvents');
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 메서드 호환성을 위한 래퍼
|
||||
async loadVideoModal(videoId) {
|
||||
try {
|
||||
// 입력값 유효성 검증
|
||||
if (!videoId || (typeof videoId !== 'number' && typeof videoId !== 'string')) {
|
||||
this._handleError(new Error(`유효하지 않은 videoId: ${videoId}`), 'loadVideoModal');
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = videoId;
|
||||
if (!id) {
|
||||
this._handleError(new Error(`유효하지 않은 videoId: ${videoId}`), 'loadVideoModal');
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.openVideo(id);
|
||||
} catch (error) {
|
||||
this._handleError(error, 'loadVideoModal', { videoId });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 코드 호환성을 위한 래퍼 메서드들
|
||||
get currentModal() {
|
||||
try {
|
||||
return this.currentModalElement;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'currentModal.get');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
set currentModal(value) {
|
||||
try {
|
||||
this.currentModalElement = value;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'currentModal.set', { value });
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 메서드 호환성 유지 (VideoModalBase의 메서드 사용)
|
||||
adjustVideoListHeight() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.adjustVideoListHeight(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'adjustVideoListHeight');
|
||||
}
|
||||
}
|
||||
|
||||
setupCommentResizer() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.setupCommentResizer(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupCommentResizer');
|
||||
}
|
||||
}
|
||||
|
||||
setupCommentBox() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.setupCommentBox(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupCommentBox');
|
||||
}
|
||||
}
|
||||
|
||||
showCommentSection() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.showCommentSection(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'showCommentSection');
|
||||
}
|
||||
}
|
||||
|
||||
adjustCommentOnlyLayout() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.adjustCommentOnlyLayout(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'adjustCommentOnlyLayout');
|
||||
}
|
||||
}
|
||||
|
||||
setupEssentialLayout() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.setupEssentialLayout(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupEssentialLayout');
|
||||
}
|
||||
}
|
||||
|
||||
setupLearningLayout() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.setupLearningLayout(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupLearningLayout');
|
||||
}
|
||||
}
|
||||
|
||||
initializeHeightAdjustment() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.initializeHeightAdjustment(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'initializeHeightAdjustment');
|
||||
}
|
||||
}
|
||||
|
||||
setupResizeObserver() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.setupResizeObserver(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupResizeObserver');
|
||||
}
|
||||
}
|
||||
|
||||
setupMutationObserver() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
super.setupMutationObserver(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'setupMutationObserver');
|
||||
}
|
||||
}
|
||||
|
||||
async waitForImagesAndAdjust() {
|
||||
try {
|
||||
if (this.currentModalElement) {
|
||||
await super.waitForImagesAndAdjust(this.currentModalElement);
|
||||
}
|
||||
} catch (error) {
|
||||
this._handleError(error, 'waitForImagesAndAdjust');
|
||||
}
|
||||
}
|
||||
|
||||
destroyModal() {
|
||||
try {
|
||||
// 이벤트 리스너 제거
|
||||
if (this.eventManager && this.listenerIds.length > 0) {
|
||||
this.listenerIds.forEach(({ element, id, type }) => {
|
||||
if (type === 'delegate') {
|
||||
this.eventManager.undelegate(element, id);
|
||||
} else if (type === 'native') {
|
||||
element.removeEventListener('click', id || arguments[0]);
|
||||
} else {
|
||||
this.eventManager.off(element, id);
|
||||
}
|
||||
});
|
||||
this.listenerIds = [];
|
||||
} else if (this.listenerIds.length > 0) {
|
||||
this.listenerIds.forEach(({ element, handler, type }) => {
|
||||
if (type === 'native' && element && handler) {
|
||||
element.removeEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
this.listenerIds = [];
|
||||
}
|
||||
|
||||
// 부모 클래스의 destroy 호출
|
||||
if (super.destroy && typeof super.destroy === 'function') {
|
||||
super.destroy();
|
||||
} else if (this.destroy && typeof this.destroy === 'function') {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
this._isInitialized = false;
|
||||
} catch (error) {
|
||||
this._handleError(error, 'destroyModal');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.VideoModalManager = VideoModalManager;
|
||||
}
|
||||
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.VideoModalManager = VideoModalManager;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { VideoModalManager };
|
||||
}
|
||||
Reference in New Issue
Block a user